diff --git a/.gitignore b/.gitignore index 5a08f0a..448e6f0 100644 --- a/.gitignore +++ b/.gitignore @@ -15,9 +15,9 @@ Desktop.ini *~ .idea/ .vscode/ -*.sublime-project -*.sublime-workspace -debug-logs/ +*.sublime-project +*.sublime-workspace +debug-logs/ # Backend (Go) /mengyamonitor-backend/mengyamonitor-backend @@ -31,6 +31,17 @@ debug-logs/ /mengyamonitor-backend/debug /mengyamonitor-backend/__debug_bin +# Central server (Gin) +/mengyamonitor-backend-server/mengyamonitor-backend-server +/mengyamonitor-backend-server/mengyamonitor-backend-server.exe +/mengyamonitor-backend-server/*.exe +/mengyamonitor-backend-server/data/ +/mengyamonitor-backend-server/release/ + +# Metrics client (采集端) +/mengyamonitor-backend-client/dist/ +/mengyamonitor-backend-client/*.exe + # Frontend (Node/React/Vite) /mengyamonitor-frontend/node_modules/ /mengyamonitor-frontend/dist/ diff --git a/mengyamonitor-backend/README.md b/mengyamonitor-backend-client/README.md similarity index 100% rename from mengyamonitor-backend/README.md rename to mengyamonitor-backend-client/README.md diff --git a/mengyamonitor-backend-client/build_linux.ps1 b/mengyamonitor-backend-client/build_linux.ps1 new file mode 100644 index 0000000..2a1295c --- /dev/null +++ b/mengyamonitor-backend-client/build_linux.ps1 @@ -0,0 +1,40 @@ +# Cross-compile Linux agent (amd64 + arm64). Same as build_linux.sh. +# Usage: cd mengyamonitor-backend-client; .\build_linux.ps1 + +$ErrorActionPreference = "Stop" +$Root = $PSScriptRoot +Set-Location $Root + +$name = if ($env:BINARY_NAME) { $env:BINARY_NAME } else { "mengyamonitor-agent" } +$out = if ($env:OUT_DIR) { $env:OUT_DIR } else { "dist" } + +New-Item -ItemType Directory -Force -Path (Join-Path $out "linux_amd64"), (Join-Path $out "linux_arm64") | Out-Null + +$env:CGO_ENABLED = "0" + +Write-Host "==> linux/amd64 -> $out/linux_amd64/$name" +$env:GOOS = "linux" +$env:GOARCH = "amd64" +try { + $destAmd = Join-Path (Join-Path $out "linux_amd64") $name + go build -trimpath -ldflags="-s -w" -o $destAmd . +} +finally { + Remove-Item Env:GOOS, Env:GOARCH -ErrorAction SilentlyContinue +} + +Write-Host "==> linux/arm64 -> $out/linux_arm64/$name" +$env:GOOS = "linux" +$env:GOARCH = "arm64" +try { + $destArm = Join-Path (Join-Path $out "linux_arm64") $name + go build -trimpath -ldflags="-s -w" -o $destArm . +} +finally { + Remove-Item Env:GOOS, Env:GOARCH -ErrorAction SilentlyContinue +} + +Write-Host "Done." +$p1 = Join-Path (Join-Path $out "linux_amd64") $name +$p2 = Join-Path (Join-Path $out "linux_arm64") $name +Get-Item -LiteralPath $p1, $p2 | Format-Table -Property FullName, Length diff --git a/mengyamonitor-backend-client/build_linux.sh b/mengyamonitor-backend-client/build_linux.sh new file mode 100644 index 0000000..9a794af --- /dev/null +++ b/mengyamonitor-backend-client/build_linux.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# 一键交叉编译 Linux 采集端(amd64 + arm64),输出与 dist/install_mengyamonitor.sh 发布路径一致。 +# +# 用法(在 mengyamonitor-backend-client 目录下): +# chmod +x build_linux.sh && ./build_linux.sh +# +# Windows: Git Bash 同上;或在项目目录执行: powershell -ExecutionPolicy Bypass -File .\build_linux.ps1 +# +# 可选环境变量: +# BINARY_NAME 默认 mengyamonitor-agent +# OUT_DIR 默认 dist(相对本脚本所在目录) + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$ROOT" + +NAME="${BINARY_NAME:-mengyamonitor-agent}" +OUT="${OUT_DIR:-dist}" + +mkdir -p "$OUT/linux_amd64" "$OUT/linux_arm64" + +export CGO_ENABLED=0 + +echo "==> linux/amd64 -> $OUT/linux_amd64/$NAME" +GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o "$OUT/linux_amd64/$NAME" . + +echo "==> linux/arm64 -> $OUT/linux_arm64/$NAME" +GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w" -o "$OUT/linux_arm64/$NAME" . + +echo "完成。产物:" +for arch in amd64 arm64; do + f="$OUT/linux_${arch}/$NAME" + if command -v ls >/dev/null 2>&1; then + ls -la "$f" + else + echo " $f" + fi +done diff --git a/mengyamonitor-backend/collector.go b/mengyamonitor-backend-client/collector.go similarity index 100% rename from mengyamonitor-backend/collector.go rename to mengyamonitor-backend-client/collector.go diff --git a/mengyamonitor-backend/cpu.go b/mengyamonitor-backend-client/cpu.go similarity index 100% rename from mengyamonitor-backend/cpu.go rename to mengyamonitor-backend-client/cpu.go diff --git a/mengyamonitor-backend-client/dashboard_payload.go b/mengyamonitor-backend-client/dashboard_payload.go new file mode 100644 index 0000000..ba29306 --- /dev/null +++ b/mengyamonitor-backend-client/dashboard_payload.go @@ -0,0 +1,25 @@ +package main + +import "encoding/json" + +// BuildDashboardPayloadJSON builds the same JSON shape the browser expects (including latency). +// collectorToCentralMs is 采集器到监控中心 TCP 建连毫秒数,失败传 -1。 +func BuildDashboardPayloadJSON(collectorToCentralMs float64) ([]byte, error) { + m, err := CollectMetrics() + if err != nil { + return nil, err + } + b, err := json.Marshal(m) + if err != nil { + return nil, err + } + var o map[string]any + if err := json.Unmarshal(b, &o); err != nil { + return nil, err + } + o["latency"] = map[string]any{ + "clientToServer": collectorToCentralMs, + "external": readExternalLatencies(), + } + return json.Marshal(o) +} diff --git a/mengyamonitor-backend/docker.go b/mengyamonitor-backend-client/docker.go similarity index 100% rename from mengyamonitor-backend/docker.go rename to mengyamonitor-backend-client/docker.go diff --git a/mengyamonitor-backend-client/gen/mengyav1/agent.pb.go b/mengyamonitor-backend-client/gen/mengyav1/agent.pb.go new file mode 100644 index 0000000..13b78e8 --- /dev/null +++ b/mengyamonitor-backend-client/gen/mengyav1/agent.pb.go @@ -0,0 +1,492 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.5 +// protoc v5.28.3 +// source: mengya/v1/agent.proto + +package mengyav1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type AgentMessage struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Payload: + // + // *AgentMessage_Hello + // *AgentMessage_Heartbeat + // *AgentMessage_Metrics + Payload isAgentMessage_Payload `protobuf_oneof:"payload"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AgentMessage) Reset() { + *x = AgentMessage{} + mi := &file_mengya_v1_agent_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AgentMessage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentMessage) ProtoMessage() {} + +func (x *AgentMessage) ProtoReflect() protoreflect.Message { + mi := &file_mengya_v1_agent_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentMessage.ProtoReflect.Descriptor instead. +func (*AgentMessage) Descriptor() ([]byte, []int) { + return file_mengya_v1_agent_proto_rawDescGZIP(), []int{0} +} + +func (x *AgentMessage) GetPayload() isAgentMessage_Payload { + if x != nil { + return x.Payload + } + return nil +} + +func (x *AgentMessage) GetHello() *Hello { + if x != nil { + if x, ok := x.Payload.(*AgentMessage_Hello); ok { + return x.Hello + } + } + return nil +} + +func (x *AgentMessage) GetHeartbeat() *Heartbeat { + if x != nil { + if x, ok := x.Payload.(*AgentMessage_Heartbeat); ok { + return x.Heartbeat + } + } + return nil +} + +func (x *AgentMessage) GetMetrics() *MetricsReport { + if x != nil { + if x, ok := x.Payload.(*AgentMessage_Metrics); ok { + return x.Metrics + } + } + return nil +} + +type isAgentMessage_Payload interface { + isAgentMessage_Payload() +} + +type AgentMessage_Hello struct { + Hello *Hello `protobuf:"bytes,1,opt,name=hello,proto3,oneof"` +} + +type AgentMessage_Heartbeat struct { + Heartbeat *Heartbeat `protobuf:"bytes,2,opt,name=heartbeat,proto3,oneof"` +} + +type AgentMessage_Metrics struct { + Metrics *MetricsReport `protobuf:"bytes,3,opt,name=metrics,proto3,oneof"` +} + +func (*AgentMessage_Hello) isAgentMessage_Payload() {} + +func (*AgentMessage_Heartbeat) isAgentMessage_Payload() {} + +func (*AgentMessage_Metrics) isAgentMessage_Payload() {} + +type Hello struct { + state protoimpl.MessageState `protogen:"open.v1"` + ServerId string `protobuf:"bytes,1,opt,name=server_id,json=serverId,proto3" json:"server_id,omitempty"` + AgentKey string `protobuf:"bytes,2,opt,name=agent_key,json=agentKey,proto3" json:"agent_key,omitempty"` + Hostname string `protobuf:"bytes,3,opt,name=hostname,proto3" json:"hostname,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Hello) Reset() { + *x = Hello{} + mi := &file_mengya_v1_agent_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Hello) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Hello) ProtoMessage() {} + +func (x *Hello) ProtoReflect() protoreflect.Message { + mi := &file_mengya_v1_agent_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Hello.ProtoReflect.Descriptor instead. +func (*Hello) Descriptor() ([]byte, []int) { + return file_mengya_v1_agent_proto_rawDescGZIP(), []int{1} +} + +func (x *Hello) GetServerId() string { + if x != nil { + return x.ServerId + } + return "" +} + +func (x *Hello) GetAgentKey() string { + if x != nil { + return x.AgentKey + } + return "" +} + +func (x *Hello) GetHostname() string { + if x != nil { + return x.Hostname + } + return "" +} + +type Heartbeat struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Heartbeat) Reset() { + *x = Heartbeat{} + mi := &file_mengya_v1_agent_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Heartbeat) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Heartbeat) ProtoMessage() {} + +func (x *Heartbeat) ProtoReflect() protoreflect.Message { + mi := &file_mengya_v1_agent_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Heartbeat.ProtoReflect.Descriptor instead. +func (*Heartbeat) Descriptor() ([]byte, []int) { + return file_mengya_v1_agent_proto_rawDescGZIP(), []int{2} +} + +type MetricsReport struct { + state protoimpl.MessageState `protogen:"open.v1"` + PayloadJson string `protobuf:"bytes,1,opt,name=payload_json,json=payloadJson,proto3" json:"payload_json,omitempty"` + CollectedAtUnixMs int64 `protobuf:"varint,2,opt,name=collected_at_unix_ms,json=collectedAtUnixMs,proto3" json:"collected_at_unix_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MetricsReport) Reset() { + *x = MetricsReport{} + mi := &file_mengya_v1_agent_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MetricsReport) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MetricsReport) ProtoMessage() {} + +func (x *MetricsReport) ProtoReflect() protoreflect.Message { + mi := &file_mengya_v1_agent_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MetricsReport.ProtoReflect.Descriptor instead. +func (*MetricsReport) Descriptor() ([]byte, []int) { + return file_mengya_v1_agent_proto_rawDescGZIP(), []int{3} +} + +func (x *MetricsReport) GetPayloadJson() string { + if x != nil { + return x.PayloadJson + } + return "" +} + +func (x *MetricsReport) GetCollectedAtUnixMs() int64 { + if x != nil { + return x.CollectedAtUnixMs + } + return 0 +} + +type ControlMessage struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Payload: + // + // *ControlMessage_Ack + Payload isControlMessage_Payload `protobuf_oneof:"payload"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ControlMessage) Reset() { + *x = ControlMessage{} + mi := &file_mengya_v1_agent_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ControlMessage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ControlMessage) ProtoMessage() {} + +func (x *ControlMessage) ProtoReflect() protoreflect.Message { + mi := &file_mengya_v1_agent_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ControlMessage.ProtoReflect.Descriptor instead. +func (*ControlMessage) Descriptor() ([]byte, []int) { + return file_mengya_v1_agent_proto_rawDescGZIP(), []int{4} +} + +func (x *ControlMessage) GetPayload() isControlMessage_Payload { + if x != nil { + return x.Payload + } + return nil +} + +func (x *ControlMessage) GetAck() *Ack { + if x != nil { + if x, ok := x.Payload.(*ControlMessage_Ack); ok { + return x.Ack + } + } + return nil +} + +type isControlMessage_Payload interface { + isControlMessage_Payload() +} + +type ControlMessage_Ack struct { + Ack *Ack `protobuf:"bytes,1,opt,name=ack,proto3,oneof"` +} + +func (*ControlMessage_Ack) isControlMessage_Payload() {} + +type Ack struct { + state protoimpl.MessageState `protogen:"open.v1"` + Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Ack) Reset() { + *x = Ack{} + mi := &file_mengya_v1_agent_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Ack) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Ack) ProtoMessage() {} + +func (x *Ack) ProtoReflect() protoreflect.Message { + mi := &file_mengya_v1_agent_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Ack.ProtoReflect.Descriptor instead. +func (*Ack) Descriptor() ([]byte, []int) { + return file_mengya_v1_agent_proto_rawDescGZIP(), []int{5} +} + +func (x *Ack) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +var File_mengya_v1_agent_proto protoreflect.FileDescriptor + +var file_mengya_v1_agent_proto_rawDesc = string([]byte{ + 0x0a, 0x15, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2f, 0x76, 0x31, 0x2f, 0x61, 0x67, 0x65, 0x6e, + 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x09, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2e, + 0x76, 0x31, 0x22, 0xaf, 0x01, 0x0a, 0x0c, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x4d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x12, 0x28, 0x0a, 0x05, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x48, + 0x65, 0x6c, 0x6c, 0x6f, 0x48, 0x00, 0x52, 0x05, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x12, 0x34, 0x0a, + 0x09, 0x68, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x14, 0x2e, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x48, 0x65, 0x61, + 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x48, 0x00, 0x52, 0x09, 0x68, 0x65, 0x61, 0x72, 0x74, 0x62, + 0x65, 0x61, 0x74, 0x12, 0x34, 0x0a, 0x07, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2e, 0x76, 0x31, + 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x48, 0x00, + 0x52, 0x07, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x42, 0x09, 0x0a, 0x07, 0x70, 0x61, 0x79, + 0x6c, 0x6f, 0x61, 0x64, 0x22, 0x5d, 0x0a, 0x05, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x12, 0x1b, 0x0a, + 0x09, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x08, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x61, 0x67, + 0x65, 0x6e, 0x74, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x61, + 0x67, 0x65, 0x6e, 0x74, 0x4b, 0x65, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e, + 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e, + 0x61, 0x6d, 0x65, 0x22, 0x0b, 0x0a, 0x09, 0x48, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, + 0x22, 0x63, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x52, 0x65, 0x70, 0x6f, 0x72, + 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x5f, 0x6a, 0x73, 0x6f, + 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, + 0x4a, 0x73, 0x6f, 0x6e, 0x12, 0x2f, 0x0a, 0x14, 0x63, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x65, + 0x64, 0x5f, 0x61, 0x74, 0x5f, 0x75, 0x6e, 0x69, 0x78, 0x5f, 0x6d, 0x73, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x03, 0x52, 0x11, 0x63, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x65, 0x64, 0x41, 0x74, 0x55, + 0x6e, 0x69, 0x78, 0x4d, 0x73, 0x22, 0x3f, 0x0a, 0x0e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, + 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x22, 0x0a, 0x03, 0x61, 0x63, 0x6b, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2e, 0x76, 0x31, + 0x2e, 0x41, 0x63, 0x6b, 0x48, 0x00, 0x52, 0x03, 0x61, 0x63, 0x6b, 0x42, 0x09, 0x0a, 0x07, 0x70, + 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x22, 0x1f, 0x0a, 0x03, 0x41, 0x63, 0x6b, 0x12, 0x18, 0x0a, + 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, + 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x32, 0x50, 0x0a, 0x0b, 0x41, 0x67, 0x65, 0x6e, 0x74, + 0x49, 0x6e, 0x67, 0x65, 0x73, 0x74, 0x12, 0x41, 0x0a, 0x07, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, + 0x74, 0x12, 0x17, 0x2e, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x67, + 0x65, 0x6e, 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x19, 0x2e, 0x6d, 0x65, 0x6e, + 0x67, 0x79, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x4d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x28, 0x01, 0x30, 0x01, 0x42, 0x3d, 0x5a, 0x3b, 0x6d, 0x65, 0x6e, + 0x67, 0x79, 0x61, 0x6d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x2d, 0x62, 0x61, 0x63, 0x6b, 0x65, + 0x6e, 0x64, 0x2d, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, + 0x61, 0x6c, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x76, 0x31, 0x3b, + 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +}) + +var ( + file_mengya_v1_agent_proto_rawDescOnce sync.Once + file_mengya_v1_agent_proto_rawDescData []byte +) + +func file_mengya_v1_agent_proto_rawDescGZIP() []byte { + file_mengya_v1_agent_proto_rawDescOnce.Do(func() { + file_mengya_v1_agent_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_mengya_v1_agent_proto_rawDesc), len(file_mengya_v1_agent_proto_rawDesc))) + }) + return file_mengya_v1_agent_proto_rawDescData +} + +var file_mengya_v1_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_mengya_v1_agent_proto_goTypes = []any{ + (*AgentMessage)(nil), // 0: mengya.v1.AgentMessage + (*Hello)(nil), // 1: mengya.v1.Hello + (*Heartbeat)(nil), // 2: mengya.v1.Heartbeat + (*MetricsReport)(nil), // 3: mengya.v1.MetricsReport + (*ControlMessage)(nil), // 4: mengya.v1.ControlMessage + (*Ack)(nil), // 5: mengya.v1.Ack +} +var file_mengya_v1_agent_proto_depIdxs = []int32{ + 1, // 0: mengya.v1.AgentMessage.hello:type_name -> mengya.v1.Hello + 2, // 1: mengya.v1.AgentMessage.heartbeat:type_name -> mengya.v1.Heartbeat + 3, // 2: mengya.v1.AgentMessage.metrics:type_name -> mengya.v1.MetricsReport + 5, // 3: mengya.v1.ControlMessage.ack:type_name -> mengya.v1.Ack + 0, // 4: mengya.v1.AgentIngest.Connect:input_type -> mengya.v1.AgentMessage + 4, // 5: mengya.v1.AgentIngest.Connect:output_type -> mengya.v1.ControlMessage + 5, // [5:6] is the sub-list for method output_type + 4, // [4:5] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name +} + +func init() { file_mengya_v1_agent_proto_init() } +func file_mengya_v1_agent_proto_init() { + if File_mengya_v1_agent_proto != nil { + return + } + file_mengya_v1_agent_proto_msgTypes[0].OneofWrappers = []any{ + (*AgentMessage_Hello)(nil), + (*AgentMessage_Heartbeat)(nil), + (*AgentMessage_Metrics)(nil), + } + file_mengya_v1_agent_proto_msgTypes[4].OneofWrappers = []any{ + (*ControlMessage_Ack)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_mengya_v1_agent_proto_rawDesc), len(file_mengya_v1_agent_proto_rawDesc)), + NumEnums: 0, + NumMessages: 6, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_mengya_v1_agent_proto_goTypes, + DependencyIndexes: file_mengya_v1_agent_proto_depIdxs, + MessageInfos: file_mengya_v1_agent_proto_msgTypes, + }.Build() + File_mengya_v1_agent_proto = out.File + file_mengya_v1_agent_proto_goTypes = nil + file_mengya_v1_agent_proto_depIdxs = nil +} diff --git a/mengyamonitor-backend-client/gen/mengyav1/agent_grpc.pb.go b/mengyamonitor-backend-client/gen/mengyav1/agent_grpc.pb.go new file mode 100644 index 0000000..d5aa41a --- /dev/null +++ b/mengyamonitor-backend-client/gen/mengyav1/agent_grpc.pb.go @@ -0,0 +1,119 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc v5.28.3 +// source: mengya/v1/agent.proto + +package mengyav1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + AgentIngest_Connect_FullMethodName = "/mengya.v1.AgentIngest/Connect" +) + +// AgentIngestClient is the client API for AgentIngest service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// AgentIngest: 采集端出站连接,双向流上报指标。 +type AgentIngestClient interface { + Connect(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[AgentMessage, ControlMessage], error) +} + +type agentIngestClient struct { + cc grpc.ClientConnInterface +} + +func NewAgentIngestClient(cc grpc.ClientConnInterface) AgentIngestClient { + return &agentIngestClient{cc} +} + +func (c *agentIngestClient) Connect(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[AgentMessage, ControlMessage], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &AgentIngest_ServiceDesc.Streams[0], AgentIngest_Connect_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[AgentMessage, ControlMessage]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type AgentIngest_ConnectClient = grpc.BidiStreamingClient[AgentMessage, ControlMessage] + +// AgentIngestServer is the server API for AgentIngest service. +// All implementations must embed UnimplementedAgentIngestServer +// for forward compatibility. +// +// AgentIngest: 采集端出站连接,双向流上报指标。 +type AgentIngestServer interface { + Connect(grpc.BidiStreamingServer[AgentMessage, ControlMessage]) error + mustEmbedUnimplementedAgentIngestServer() +} + +// UnimplementedAgentIngestServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedAgentIngestServer struct{} + +func (UnimplementedAgentIngestServer) Connect(grpc.BidiStreamingServer[AgentMessage, ControlMessage]) error { + return status.Errorf(codes.Unimplemented, "method Connect not implemented") +} +func (UnimplementedAgentIngestServer) mustEmbedUnimplementedAgentIngestServer() {} +func (UnimplementedAgentIngestServer) testEmbeddedByValue() {} + +// UnsafeAgentIngestServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to AgentIngestServer will +// result in compilation errors. +type UnsafeAgentIngestServer interface { + mustEmbedUnimplementedAgentIngestServer() +} + +func RegisterAgentIngestServer(s grpc.ServiceRegistrar, srv AgentIngestServer) { + // If the following call pancis, it indicates UnimplementedAgentIngestServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&AgentIngest_ServiceDesc, srv) +} + +func _AgentIngest_Connect_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(AgentIngestServer).Connect(&grpc.GenericServerStream[AgentMessage, ControlMessage]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type AgentIngest_ConnectServer = grpc.BidiStreamingServer[AgentMessage, ControlMessage] + +// AgentIngest_ServiceDesc is the grpc.ServiceDesc for AgentIngest service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var AgentIngest_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "mengya.v1.AgentIngest", + HandlerType: (*AgentIngestServer)(nil), + Methods: []grpc.MethodDesc{}, + Streams: []grpc.StreamDesc{ + { + StreamName: "Connect", + Handler: _AgentIngest_Connect_Handler, + ServerStreams: true, + ClientStreams: true, + }, + }, + Metadata: "mengya/v1/agent.proto", +} diff --git a/mengyamonitor-backend-client/go.mod b/mengyamonitor-backend-client/go.mod new file mode 100644 index 0000000..dbbcf95 --- /dev/null +++ b/mengyamonitor-backend-client/go.mod @@ -0,0 +1,15 @@ +module mengyamonitor-backend + +go 1.22 + +require ( + google.golang.org/grpc v1.70.0 + google.golang.org/protobuf v1.35.2 +) + +require ( + golang.org/x/net v0.32.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/text v0.21.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a // indirect +) diff --git a/mengyamonitor-backend-client/go.sum b/mengyamonitor-backend-client/go.sum new file mode 100644 index 0000000..3962e2d --- /dev/null +++ b/mengyamonitor-backend-client/go.sum @@ -0,0 +1,32 @@ +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U= +go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg= +go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M= +go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8= +go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4= +go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU= +go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU= +go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ= +go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM= +go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8= +golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI= +golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a h1:hgh8P4EuoxpsuKMXX/To36nOFD7vixReXgn8lPGnt+o= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU= +google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ= +google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw= +google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= +google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= diff --git a/mengyamonitor-backend/gpu.go b/mengyamonitor-backend-client/gpu.go similarity index 100% rename from mengyamonitor-backend/gpu.go rename to mengyamonitor-backend-client/gpu.go diff --git a/mengyamonitor-backend-client/grpc_reporter.go b/mengyamonitor-backend-client/grpc_reporter.go new file mode 100644 index 0000000..c9089f8 --- /dev/null +++ b/mengyamonitor-backend-client/grpc_reporter.go @@ -0,0 +1,96 @@ +package main + +import ( + "context" + "io" + "log" + "os" + "time" + + mengyav1 "mengyamonitor-backend/gen/mengyav1" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +func runGRPCLoop(addr, serverID, key string) { + backoff := time.Second + const maxBackoff = 30 * time.Second + for { + err := runGRPCSession(addr, serverID, key) + if err != nil { + log.Printf("grpc reporter: session ended: %v (reconnect in %v)", err, backoff) + time.Sleep(backoff) + if backoff < maxBackoff { + backoff *= 2 + } + continue + } + backoff = time.Second + } +} + +func runGRPCSession(addr, serverID, key string) error { + ctx := context.Background() + conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return err + } + defer conn.Close() + + cli := mengyav1.NewAgentIngestClient(conn) + stream, err := cli.Connect(ctx) + if err != nil { + return err + } + + host, _ := os.Hostname() + if err := stream.Send(&mengyav1.AgentMessage{ + Payload: &mengyav1.AgentMessage_Hello{ + Hello: &mengyav1.Hello{ServerId: serverID, AgentKey: key, Hostname: host}, + }, + }); err != nil { + return err + } + + errCh := make(chan error, 1) + go func() { + for { + _, err := stream.Recv() + if err != nil { + errCh <- err + return + } + } + }() + + tick := time.NewTicker(2 * time.Second) + defer tick.Stop() + + for { + select { + case err := <-errCh: + if err == io.EOF { + return nil + } + return err + case <-tick.C: + centralMs := measureCollectorToCentralTCP(addr, 3*time.Second) + payload, err := BuildDashboardPayloadJSON(centralMs) + if err != nil { + log.Printf("grpc reporter: collect: %v", err) + continue + } + if err := stream.Send(&mengyav1.AgentMessage{ + Payload: &mengyav1.AgentMessage_Metrics{ + Metrics: &mengyav1.MetricsReport{ + PayloadJson: string(payload), + CollectedAtUnixMs: time.Now().UnixMilli(), + }, + }, + }); err != nil { + return err + } + } + } +} diff --git a/mengyamonitor-backend-client/latency.go b/mengyamonitor-backend-client/latency.go new file mode 100644 index 0000000..98efd15 --- /dev/null +++ b/mengyamonitor-backend-client/latency.go @@ -0,0 +1,78 @@ +package main + +import ( + "fmt" + "net" + "strings" + "time" +) + +// LatencyInfo 延迟信息(均由采集端在本机测量) +type LatencyInfo struct { + ClientToServer float64 `json:"clientToServer"` // 采集器 → 监控中心 gRPC 地址的 TCP 建连耗时(ms),失败为 -1 + External map[string]string `json:"external"` // 采集器对外站点的 TCP 建连耗时(同 checkExternalLatency) +} + +// normalizeGRPCDialTarget strips common grpc-go target prefixes; expects host:port or [ipv6]:port. +func normalizeGRPCDialTarget(addr string) string { + a := strings.TrimSpace(addr) + a = strings.TrimPrefix(a, "dns:///") + a = strings.TrimPrefix(a, "dns://") + return strings.TrimSpace(a) +} + +// measureCollectorToCentralTCP returns TCP connect time in ms to the central gRPC dial target, or -1 on failure. +func measureCollectorToCentralTCP(grpcAddr string, timeout time.Duration) float64 { + target := normalizeGRPCDialTarget(grpcAddr) + if target == "" || strings.HasPrefix(strings.ToLower(target), "unix:") { + return -1 + } + start := time.Now() + conn, err := net.DialTimeout("tcp", target, timeout) + if err != nil { + return -1 + } + _ = conn.Close() + return float64(time.Since(start).Microseconds()) / 1000.0 +} + +// checkExternalLatency 检测外部网站延迟 +func checkExternalLatency(host string, timeout time.Duration) string { + start := time.Now() + + // 尝试 TCP 连接 80 端口 + conn, err := net.DialTimeout("tcp", host+":80", timeout) + if err != nil { + // 如果 80 端口失败,尝试 443 (HTTPS) + conn, err = net.DialTimeout("tcp", host+":443", timeout) + if err != nil { + return "超时" + } + } + defer conn.Close() + + // 使用微秒换算,避免 Milliseconds() 截断成 0 ms 误导用户 + ms := float64(time.Since(start).Microseconds()) / 1000.0 + if ms < 1 { + return fmt.Sprintf("%.1f ms", ms) + } + return fmt.Sprintf("%.0f ms", ms) +} + +// readExternalLatencies 读取外部网站延迟 +func readExternalLatencies() map[string]string { + latencies := make(map[string]string) + timeout := 3 * time.Second + + // 检测百度 + latencies["baidu.com"] = checkExternalLatency("baidu.com", timeout) + + // 检测谷歌 + latencies["google.com"] = checkExternalLatency("google.com", timeout) + + // 检测 GitHub + latencies["github.com"] = checkExternalLatency("github.com", timeout) + + return latencies +} + diff --git a/mengyamonitor-backend-client/main.go b/mengyamonitor-backend-client/main.go new file mode 100644 index 0000000..3eeb8bc --- /dev/null +++ b/mengyamonitor-backend-client/main.go @@ -0,0 +1,17 @@ +package main + +import ( + "log" + "os" +) + +func main() { + addr := os.Getenv("CENTRAL_GRPC") + serverID := os.Getenv("SERVER_ID") + key := os.Getenv("AGENT_KEY") + if addr == "" || serverID == "" || key == "" { + log.Fatal("mengyamonitor agent requires CENTRAL_GRPC, SERVER_ID, and AGENT_KEY") + } + log.Printf("agent reporting via gRPC to %s (server_id=%s)", addr, serverID) + runGRPCLoop(addr, serverID, key) +} diff --git a/mengyamonitor-backend-client/memory.go b/mengyamonitor-backend-client/memory.go new file mode 100644 index 0000000..e0b0229 --- /dev/null +++ b/mengyamonitor-backend-client/memory.go @@ -0,0 +1,127 @@ +package main + +import ( + "bufio" + "os" + "sort" + "strconv" + "strings" +) + +// readMemory 读取内存信息 +func readMemory() (MemoryMetrics, error) { + totals := map[string]uint64{} + f, err := os.Open("/proc/meminfo") + if err != nil { + return MemoryMetrics{}, err + } + defer f.Close() + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + parts := strings.Split(line, ":") + if len(parts) < 2 { + continue + } + key := strings.TrimSpace(parts[0]) + fields := strings.Fields(strings.TrimSpace(parts[1])) + if len(fields) == 0 { + continue + } + value, err := strconv.ParseUint(fields[0], 10, 64) + if err != nil { + continue + } + totals[key] = value * 1024 // kB to bytes + } + total := totals["MemTotal"] + + // 优先使用 MemAvailable(Linux 3.14+),如果没有则计算 + var free uint64 + if available, ok := totals["MemAvailable"]; ok && available > 0 { + free = available + } else { + // 回退到 MemFree + Buffers + Cached(适用于较老的系统) + free = totals["MemFree"] + if buffers, ok := totals["Buffers"]; ok { + free += buffers + } + if cached, ok := totals["Cached"]; ok { + free += cached + } + } + + used := total - free + usedPercent := 0.0 + if total > 0 { + usedPercent = (float64(used) / float64(total)) * 100 + } + + swapTotal := totals["SwapTotal"] + swapFree := totals["SwapFree"] + swapUsed := uint64(0) + if swapTotal >= swapFree { + swapUsed = swapTotal - swapFree + } + swapUsedPct := 0.0 + if swapTotal > 0 { + swapUsedPct = (float64(swapUsed) / float64(swapTotal)) * 100 + } + swapType := summarizeSwapTypes(swapTotal) + + return MemoryMetrics{ + TotalBytes: total, + UsedBytes: used, + FreeBytes: free, + UsedPercent: round(usedPercent, 2), + SwapTotalBytes: swapTotal, + SwapUsedBytes: swapUsed, + SwapFreeBytes: swapFree, + SwapUsedPercent: round(swapUsedPct, 2), + SwapType: swapType, + }, nil +} + +// summarizeSwapTypes 根据 /proc/swaps 汇总交换区类型(file / partition / zram 等);无 swap 时为「无」 +func summarizeSwapTypes(swapTotalFromMeminfo uint64) string { + if swapTotalFromMeminfo == 0 { + return "无" + } + f, err := os.Open("/proc/swaps") + if err != nil { + return "未知" + } + defer f.Close() + scanner := bufio.NewScanner(f) + lineNum := 0 + seen := map[string]struct{}{} + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + lineNum++ + if lineNum == 1 || line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + filename := fields[0] + typ := fields[1] + if strings.Contains(strings.ToLower(filename), "zram") { + typ = "zram" + } + seen[typ] = struct{}{} + } + if len(seen) == 0 { + if swapTotalFromMeminfo > 0 { + return "未知" + } + return "无" + } + list := make([]string, 0, len(seen)) + for t := range seen { + list = append(list, t) + } + sort.Strings(list) + return strings.Join(list, " + ") +} diff --git a/mengyamonitor-backend/network.go b/mengyamonitor-backend-client/network.go similarity index 100% rename from mengyamonitor-backend/network.go rename to mengyamonitor-backend-client/network.go diff --git a/mengyamonitor-backend/system.go b/mengyamonitor-backend-client/system.go similarity index 85% rename from mengyamonitor-backend/system.go rename to mengyamonitor-backend-client/system.go index 0d81d1c..a13a9b4 100644 --- a/mengyamonitor-backend/system.go +++ b/mengyamonitor-backend-client/system.go @@ -1,390 +1,436 @@ -package main - -import ( - "bufio" - "context" - "os" - "os/exec" - "strconv" - "strings" - "time" -) - -// readSystemStats 读取系统统计信息 -func readSystemStats() SystemStats { - stats := SystemStats{} - - // 读取进程数量 - stats.ProcessCount = countProcesses() - - // 读取已安装包数量和包管理器类型 - stats.PackageCount, stats.PackageManager = countPackages() - - // 读取系统温度 - stats.Temperature = readSystemTemperature() - - // 读取磁盘读写速度 - stats.DiskReadSpeed, stats.DiskWriteSpeed = readDiskSpeed() - - // 读取 Top 5 进程 - stats.TopProcesses = readTopProcesses() - - // 读取系统日志 - stats.SystemLogs = readSystemLogs(10) - - return stats -} - -func countProcesses() int { - entries, err := os.ReadDir("/proc") - if err != nil { - return 0 - } - count := 0 - for _, entry := range entries { - if entry.IsDir() { - // 进程目录是数字命名的 - if _, err := strconv.Atoi(entry.Name()); err == nil { - count++ - } - } - } - return count -} - -func countPackages() (int, string) { - // 尝试不同的包管理器 - // dpkg (Debian/Ubuntu) - if _, err := exec.LookPath("dpkg"); err == nil { - cmd := exec.Command("dpkg", "-l") - out, err := cmd.Output() - if err == nil { - lines := strings.Split(string(out), "\n") - count := 0 - for _, line := range lines { - if strings.HasPrefix(line, "ii ") { - count++ - } - } - return count, "dpkg (apt)" - } - } - - // rpm (RedHat/CentOS/Fedora) - if _, err := exec.LookPath("rpm"); err == nil { - cmd := exec.Command("rpm", "-qa") - out, err := cmd.Output() - if err == nil { - lines := strings.Split(strings.TrimSpace(string(out)), "\n") - return len(lines), "rpm (yum/dnf)" - } - } - - // pacman (Arch Linux) - if _, err := exec.LookPath("pacman"); err == nil { - cmd := exec.Command("pacman", "-Q") - out, err := cmd.Output() - if err == nil { - lines := strings.Split(strings.TrimSpace(string(out)), "\n") - return len(lines), "pacman" - } - } - - return 0, "unknown" -} - -func readSystemTemperature() float64 { - var cpuTemp float64 = 0 - var fallbackTemp float64 = 0 - - // 1. 优先读取 thermal_zone (通常是 CPU 温度) - thermalDir := "/sys/class/thermal" - entries, err := os.ReadDir(thermalDir) - if err == nil { - for _, entry := range entries { - if !strings.HasPrefix(entry.Name(), "thermal_zone") { - continue - } - tempPath := thermalDir + "/" + entry.Name() + "/temp" - if temp := readTempFromFile(tempPath); temp > 0 && temp > 20 && temp < 120 { - // thermal_zone0 通常是 CPU - if entry.Name() == "thermal_zone0" { - cpuTemp = temp - break - } else if fallbackTemp == 0 { - fallbackTemp = temp - } - } - } - } - - // 2. 扫描所有 hwmon 设备,查找 CPU 温度 - hwmonDir := "/sys/class/hwmon" - entries, err = os.ReadDir(hwmonDir) - if err == nil { - for _, entry := range entries { - hwmonPath := hwmonDir + "/" + entry.Name() - - // 读取 name 文件,检查是否是 CPU 相关 - namePath := hwmonPath + "/name" - name := strings.ToLower(strings.TrimSpace(readFirstLine(namePath))) - - // 检查是否是 CPU 温度传感器 - isCPU := strings.Contains(name, "cpu") || - strings.Contains(name, "core") || - strings.Contains(name, "k10temp") || - strings.Contains(name, "coretemp") || - strings.Contains(name, "zenpower") - - // 尝试读取 temp1_input (通常是 CPU) - temp1Path := hwmonPath + "/temp1_input" - if temp := readTempFromFile(temp1Path); temp > 0 && temp > 20 && temp < 120 { - if isCPU { - cpuTemp = temp - break - } else if fallbackTemp == 0 { - fallbackTemp = temp - } - } - - // 也尝试 temp2_input - temp2Path := hwmonPath + "/temp2_input" - if temp := readTempFromFile(temp2Path); temp > 0 && temp > 20 && temp < 120 { - if isCPU && cpuTemp == 0 { - cpuTemp = temp - } else if fallbackTemp == 0 { - fallbackTemp = temp - } - } - } - } - - // 优先返回 CPU 温度,如果没有则返回其他温度 - if cpuTemp > 0 { - return cpuTemp - } - return fallbackTemp -} - -// readDiskSpeed 读取磁盘瞬时读写速度 (MB/s) -func readDiskSpeed() (float64, float64) { - // 第一次读取 - readSectors1, writeSectors1 := getDiskSectors() - if readSectors1 == 0 && writeSectors1 == 0 { - return 0, 0 - } - - // 等待1秒 - time.Sleep(1 * time.Second) - - // 第二次读取 - readSectors2, writeSectors2 := getDiskSectors() - - // 计算差值(扇区数) - readDiff := readSectors2 - readSectors1 - writeDiff := writeSectors2 - writeSectors1 - - // 扇区大小通常是 512 字节,转换为 MB/s - readSpeed := float64(readDiff) * 512 / 1024 / 1024 - writeSpeed := float64(writeDiff) * 512 / 1024 / 1024 - - return round(readSpeed, 2), round(writeSpeed, 2) -} - -func getDiskSectors() (uint64, uint64) { - f, err := os.Open("/proc/diskstats") - if err != nil { - return 0, 0 - } - defer f.Close() - - scanner := bufio.NewScanner(f) - - var maxRead uint64 = 0 - var mainDevice string - - // 第一次遍历:找到读写量最大的主磁盘(通常是系统盘) - for scanner.Scan() { - line := scanner.Text() - fields := strings.Fields(line) - if len(fields) < 14 { - continue - } - - deviceName := fields[2] - // 跳过分区(分区名通常包含数字,如 sda1, vda1, nvme0n1p1) - if strings.ContainsAny(deviceName, "0123456789") && - !strings.HasPrefix(deviceName, "nvme") && - !strings.HasPrefix(deviceName, "loop") { - continue - } - - // 跳过虚拟设备 - if strings.HasPrefix(deviceName, "loop") || - strings.HasPrefix(deviceName, "ram") || - strings.HasPrefix(deviceName, "zram") { - continue - } - - readSectors, _ := strconv.ParseUint(fields[5], 10, 64) - // 选择读写量最大的作为主磁盘 - if readSectors > maxRead { - maxRead = readSectors - mainDevice = deviceName - } - } - - // 第二次遍历:读取主磁盘的数据 - f.Close() - f, err = os.Open("/proc/diskstats") - if err != nil { - return 0, 0 - } - scanner = bufio.NewScanner(f) - for scanner.Scan() { - line := scanner.Text() - fields := strings.Fields(line) - if len(fields) < 14 { - continue - } - - if fields[2] == mainDevice { - readSectors, _ := strconv.ParseUint(fields[5], 10, 64) - writeSectors, _ := strconv.ParseUint(fields[9], 10, 64) - return readSectors, writeSectors - } - } - - // 如果没找到,尝试常见的设备名(向后兼容) - f.Close() - f, err = os.Open("/proc/diskstats") - if err != nil { - return 0, 0 - } - scanner = bufio.NewScanner(f) - for scanner.Scan() { - line := scanner.Text() - fields := strings.Fields(line) - if len(fields) < 14 { - continue - } - - deviceName := fields[2] - if deviceName == "sda" || deviceName == "vda" || deviceName == "nvme0n1" { - readSectors, _ := strconv.ParseUint(fields[5], 10, 64) - writeSectors, _ := strconv.ParseUint(fields[9], 10, 64) - return readSectors, writeSectors - } - } - - return 0, 0 -} - -// readTopProcesses 读取 Top 5 进程 (按 CPU 使用率) -func readTopProcesses() []ProcessInfo { - processes := []ProcessInfo{} - - // 读取系统总内存 - memInfo, _ := readMemory() - totalMemGB := float64(memInfo.TotalBytes) / 1024 / 1024 / 1024 - - // 使用 ps 命令获取进程信息,添加超时控制 - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - cmd := exec.CommandContext(ctx, "ps", "aux", "--sort=-%cpu", "--no-headers") - out, err := cmd.Output() - if err != nil { - return processes - } - - lines := strings.Split(string(out), "\n") - count := 0 - - for _, line := range lines { - if count >= 5 { // 只取前5个 - break - } - - line = strings.TrimSpace(line) - if line == "" { - continue - } - - fields := strings.Fields(line) - if len(fields) < 11 { - continue - } - - pid, _ := strconv.Atoi(fields[1]) - cpu, _ := strconv.ParseFloat(fields[2], 64) - mem, _ := strconv.ParseFloat(fields[3], 64) - - // 计算内存MB数 - memoryMB := (mem / 100) * totalMemGB * 1024 - - // 命令可能包含空格,从第11个字段开始拼接 - command := strings.Join(fields[10:], " ") - if len(command) > 50 { - command = command[:50] + "..." - } - - processes = append(processes, ProcessInfo{ - PID: pid, - Name: fields[10], - CPU: round(cpu, 1), - Memory: round(mem, 1), - MemoryMB: round(memoryMB, 1), - Command: command, - }) - count++ - } - - return processes -} - -// readSystemLogs 读取系统最新日志 -func readSystemLogs(count int) []string { - logs := []string{} - - // 尝试使用 journalctl 读取系统日志 - if _, err := exec.LookPath("journalctl"); err == nil { - cmd := exec.Command("journalctl", "-n", strconv.Itoa(count), "--no-pager", "-o", "short") - out, err := cmd.Output() - if err == nil { - lines := strings.Split(strings.TrimSpace(string(out)), "\n") - for _, line := range lines { - if line != "" { - logs = append(logs, line) - } - } - return logs - } - } - - // 如果 journalctl 不可用,尝试读取 /var/log/syslog 或 /var/log/messages - logFiles := []string{"/var/log/syslog", "/var/log/messages"} - for _, logFile := range logFiles { - f, err := os.Open(logFile) - if err != nil { - continue - } - defer f.Close() - - // 读取最后几行 - var lines []string - scanner := bufio.NewScanner(f) - for scanner.Scan() { - lines = append(lines, scanner.Text()) - } - - // 取最后count行 - start := len(lines) - count - if start < 0 { - start = 0 - } - logs = lines[start:] - break - } - - return logs -} +package main + +import ( + "bufio" + "context" + "os" + "os/exec" + "strconv" + "strings" + "time" +) + +// readSystemStats 读取系统统计信息 +func readSystemStats() SystemStats { + stats := SystemStats{} + + // 读取进程数量 + stats.ProcessCount = countProcesses() + stats.LoggedInUsers = countLoggedInSessions() + stats.TCPConnections = countTCPConnectionsFromProc() + + // 读取已安装包数量和包管理器类型 + stats.PackageCount, stats.PackageManager = countPackages() + + // 读取系统温度 + stats.Temperature = readSystemTemperature() + + // 读取磁盘读写速度 + stats.DiskReadSpeed, stats.DiskWriteSpeed = readDiskSpeed() + + // 读取 Top 10 进程 + stats.TopProcesses = readTopProcesses() + + // 读取系统日志 + stats.SystemLogs = readSystemLogs(10) + + return stats +} + +func countProcesses() int { + entries, err := os.ReadDir("/proc") + if err != nil { + return 0 + } + count := 0 + for _, entry := range entries { + if entry.IsDir() { + // 进程目录是数字命名的 + if _, err := strconv.Atoi(entry.Name()); err == nil { + count++ + } + } + } + return count +} + +// countLoggedInSessions 当前登录会话数(与 who 输出行数一致:多个 SSH/终端各占一行) +func countLoggedInSessions() int { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "who") + out, err := cmd.Output() + if err != nil { + return 0 + } + s := strings.TrimSpace(string(out)) + if s == "" { + return 0 + } + return len(strings.Split(s, "\n")) +} + +// countTCPConnectionsFromProc 统计 /proc/net/tcp 与 tcp6 中的套接字条目数(内核导出的各行状态,非仅 ESTABLISHED) +func countTCPConnectionsFromProc() int { + return countProcNetTCPFile("/proc/net/tcp") + countProcNetTCPFile("/proc/net/tcp6") +} + +func countProcNetTCPFile(path string) int { + f, err := os.Open(path) + if err != nil { + return 0 + } + defer f.Close() + scanner := bufio.NewScanner(f) + n := 0 + header := true + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + if header { + header = false + continue + } + n++ + } + return n +} + +func countPackages() (int, string) { + // 尝试不同的包管理器 + // dpkg (Debian/Ubuntu) + if _, err := exec.LookPath("dpkg"); err == nil { + cmd := exec.Command("dpkg", "-l") + out, err := cmd.Output() + if err == nil { + lines := strings.Split(string(out), "\n") + count := 0 + for _, line := range lines { + if strings.HasPrefix(line, "ii ") { + count++ + } + } + return count, "dpkg (apt)" + } + } + + // rpm (RedHat/CentOS/Fedora) + if _, err := exec.LookPath("rpm"); err == nil { + cmd := exec.Command("rpm", "-qa") + out, err := cmd.Output() + if err == nil { + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + return len(lines), "rpm (yum/dnf)" + } + } + + // pacman (Arch Linux) + if _, err := exec.LookPath("pacman"); err == nil { + cmd := exec.Command("pacman", "-Q") + out, err := cmd.Output() + if err == nil { + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + return len(lines), "pacman" + } + } + + return 0, "unknown" +} + +func readSystemTemperature() float64 { + var cpuTemp float64 = 0 + var fallbackTemp float64 = 0 + + // 1. 优先读取 thermal_zone (通常是 CPU 温度) + thermalDir := "/sys/class/thermal" + entries, err := os.ReadDir(thermalDir) + if err == nil { + for _, entry := range entries { + if !strings.HasPrefix(entry.Name(), "thermal_zone") { + continue + } + tempPath := thermalDir + "/" + entry.Name() + "/temp" + if temp := readTempFromFile(tempPath); temp > 0 && temp > 20 && temp < 120 { + // thermal_zone0 通常是 CPU + if entry.Name() == "thermal_zone0" { + cpuTemp = temp + break + } else if fallbackTemp == 0 { + fallbackTemp = temp + } + } + } + } + + // 2. 扫描所有 hwmon 设备,查找 CPU 温度 + hwmonDir := "/sys/class/hwmon" + entries, err = os.ReadDir(hwmonDir) + if err == nil { + for _, entry := range entries { + hwmonPath := hwmonDir + "/" + entry.Name() + + // 读取 name 文件,检查是否是 CPU 相关 + namePath := hwmonPath + "/name" + name := strings.ToLower(strings.TrimSpace(readFirstLine(namePath))) + + // 检查是否是 CPU 温度传感器 + isCPU := strings.Contains(name, "cpu") || + strings.Contains(name, "core") || + strings.Contains(name, "k10temp") || + strings.Contains(name, "coretemp") || + strings.Contains(name, "zenpower") + + // 尝试读取 temp1_input (通常是 CPU) + temp1Path := hwmonPath + "/temp1_input" + if temp := readTempFromFile(temp1Path); temp > 0 && temp > 20 && temp < 120 { + if isCPU { + cpuTemp = temp + break + } else if fallbackTemp == 0 { + fallbackTemp = temp + } + } + + // 也尝试 temp2_input + temp2Path := hwmonPath + "/temp2_input" + if temp := readTempFromFile(temp2Path); temp > 0 && temp > 20 && temp < 120 { + if isCPU && cpuTemp == 0 { + cpuTemp = temp + } else if fallbackTemp == 0 { + fallbackTemp = temp + } + } + } + } + + // 优先返回 CPU 温度,如果没有则返回其他温度 + if cpuTemp > 0 { + return cpuTemp + } + return fallbackTemp +} + +// readDiskSpeed 读取磁盘瞬时读写速度 (MB/s) +func readDiskSpeed() (float64, float64) { + // 第一次读取 + readSectors1, writeSectors1 := getDiskSectors() + if readSectors1 == 0 && writeSectors1 == 0 { + return 0, 0 + } + + // 等待1秒 + time.Sleep(1 * time.Second) + + // 第二次读取 + readSectors2, writeSectors2 := getDiskSectors() + + // 计算差值(扇区数) + readDiff := readSectors2 - readSectors1 + writeDiff := writeSectors2 - writeSectors1 + + // 扇区大小通常是 512 字节,转换为 MB/s + readSpeed := float64(readDiff) * 512 / 1024 / 1024 + writeSpeed := float64(writeDiff) * 512 / 1024 / 1024 + + return round(readSpeed, 2), round(writeSpeed, 2) +} + +func getDiskSectors() (uint64, uint64) { + f, err := os.Open("/proc/diskstats") + if err != nil { + return 0, 0 + } + defer f.Close() + + scanner := bufio.NewScanner(f) + + var maxRead uint64 = 0 + var mainDevice string + + // 第一次遍历:找到读写量最大的主磁盘(通常是系统盘) + for scanner.Scan() { + line := scanner.Text() + fields := strings.Fields(line) + if len(fields) < 14 { + continue + } + + deviceName := fields[2] + // 跳过分区(分区名通常包含数字,如 sda1, vda1, nvme0n1p1) + if strings.ContainsAny(deviceName, "0123456789") && + !strings.HasPrefix(deviceName, "nvme") && + !strings.HasPrefix(deviceName, "loop") { + continue + } + + // 跳过虚拟设备 + if strings.HasPrefix(deviceName, "loop") || + strings.HasPrefix(deviceName, "ram") || + strings.HasPrefix(deviceName, "zram") { + continue + } + + readSectors, _ := strconv.ParseUint(fields[5], 10, 64) + // 选择读写量最大的作为主磁盘 + if readSectors > maxRead { + maxRead = readSectors + mainDevice = deviceName + } + } + + // 第二次遍历:读取主磁盘的数据 + f.Close() + f, err = os.Open("/proc/diskstats") + if err != nil { + return 0, 0 + } + scanner = bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + fields := strings.Fields(line) + if len(fields) < 14 { + continue + } + + if fields[2] == mainDevice { + readSectors, _ := strconv.ParseUint(fields[5], 10, 64) + writeSectors, _ := strconv.ParseUint(fields[9], 10, 64) + return readSectors, writeSectors + } + } + + // 如果没找到,尝试常见的设备名(向后兼容) + f.Close() + f, err = os.Open("/proc/diskstats") + if err != nil { + return 0, 0 + } + scanner = bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + fields := strings.Fields(line) + if len(fields) < 14 { + continue + } + + deviceName := fields[2] + if deviceName == "sda" || deviceName == "vda" || deviceName == "nvme0n1" { + readSectors, _ := strconv.ParseUint(fields[5], 10, 64) + writeSectors, _ := strconv.ParseUint(fields[9], 10, 64) + return readSectors, writeSectors + } + } + + return 0, 0 +} + +// readTopProcesses 读取 Top 10 进程 (按 CPU 使用率) +func readTopProcesses() []ProcessInfo { + processes := []ProcessInfo{} + + // 读取系统总内存 + memInfo, _ := readMemory() + totalMemGB := float64(memInfo.TotalBytes) / 1024 / 1024 / 1024 + + // 使用 ps 命令获取进程信息,添加超时控制 + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "ps", "aux", "--sort=-%cpu", "--no-headers") + out, err := cmd.Output() + if err != nil { + return processes + } + + lines := strings.Split(string(out), "\n") + count := 0 + + for _, line := range lines { + if count >= 10 { // 只取前 10 个 + break + } + + line = strings.TrimSpace(line) + if line == "" { + continue + } + + fields := strings.Fields(line) + if len(fields) < 11 { + continue + } + + pid, _ := strconv.Atoi(fields[1]) + cpu, _ := strconv.ParseFloat(fields[2], 64) + mem, _ := strconv.ParseFloat(fields[3], 64) + + // 计算内存MB数 + memoryMB := (mem / 100) * totalMemGB * 1024 + + // 命令可能包含空格,从第11个字段开始拼接 + command := strings.Join(fields[10:], " ") + if len(command) > 50 { + command = command[:50] + "..." + } + + processes = append(processes, ProcessInfo{ + PID: pid, + Name: fields[10], + CPU: round(cpu, 1), + Memory: round(mem, 1), + MemoryMB: round(memoryMB, 1), + Command: command, + }) + count++ + } + + return processes +} + +// readSystemLogs 读取系统最新日志 +func readSystemLogs(count int) []string { + logs := []string{} + + // 尝试使用 journalctl 读取系统日志 + if _, err := exec.LookPath("journalctl"); err == nil { + cmd := exec.Command("journalctl", "-n", strconv.Itoa(count), "--no-pager", "-o", "short") + out, err := cmd.Output() + if err == nil { + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + for _, line := range lines { + if line != "" { + logs = append(logs, line) + } + } + return logs + } + } + + // 如果 journalctl 不可用,尝试读取 /var/log/syslog 或 /var/log/messages + logFiles := []string{"/var/log/syslog", "/var/log/messages"} + for _, logFile := range logFiles { + f, err := os.Open(logFile) + if err != nil { + continue + } + defer f.Close() + + // 读取最后几行 + var lines []string + scanner := bufio.NewScanner(f) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + + // 取最后count行 + start := len(lines) - count + if start < 0 { + start = 0 + } + logs = lines[start:] + break + } + + return logs +} diff --git a/mengyamonitor-backend/systeminfo_linux.go b/mengyamonitor-backend-client/systeminfo_linux.go similarity index 100% rename from mengyamonitor-backend/systeminfo_linux.go rename to mengyamonitor-backend-client/systeminfo_linux.go diff --git a/mengyamonitor-backend/systeminfo_other.go b/mengyamonitor-backend-client/systeminfo_other.go similarity index 100% rename from mengyamonitor-backend/systeminfo_other.go rename to mengyamonitor-backend-client/systeminfo_other.go diff --git a/mengyamonitor-backend/types.go b/mengyamonitor-backend-client/types.go similarity index 73% rename from mengyamonitor-backend/types.go rename to mengyamonitor-backend-client/types.go index c7a1885..2f67216 100644 --- a/mengyamonitor-backend/types.go +++ b/mengyamonitor-backend-client/types.go @@ -19,10 +19,15 @@ type CoreUsage struct { // 内存相关信息 type MemoryMetrics struct { - TotalBytes uint64 `json:"totalBytes"` - UsedBytes uint64 `json:"usedBytes"` - FreeBytes uint64 `json:"freeBytes"` - UsedPercent float64 `json:"usedPercent"` + TotalBytes uint64 `json:"totalBytes"` + UsedBytes uint64 `json:"usedBytes"` + FreeBytes uint64 `json:"freeBytes"` + UsedPercent float64 `json:"usedPercent"` + SwapTotalBytes uint64 `json:"swapTotalBytes"` + SwapUsedBytes uint64 `json:"swapUsedBytes"` + SwapFreeBytes uint64 `json:"swapFreeBytes"` + SwapUsedPercent float64 `json:"swapUsedPercent"` + SwapType string `json:"swapType"` // /proc/swaps 类型汇总,如 file、partition、zram;无交换分区时为「无」 } // 储存相关信息 @@ -58,6 +63,8 @@ type NetworkInterface struct { // 系统状态相关信息 type SystemStats struct { ProcessCount int `json:"processCount"` // 进程数量 + LoggedInUsers int `json:"loggedInUsers"` // 当前登录会话数(who 行数,与 ssh/tty 会话对应) + TCPConnections int `json:"tcpConnections"` // IPv4+IPv6 套接字行数(/proc/net/tcp + tcp6,含各状态) PackageCount int `json:"packageCount"` // 已安装软件包数量 PackageManager string `json:"packageManager"` // 包管理器类型 Temperature float64 `json:"temperature,omitempty"` // 系统温度(摄氏度) @@ -65,7 +72,7 @@ type SystemStats struct { DiskWriteSpeed float64 `json:"diskWriteSpeed"` // 磁盘写入速度 MB/s NetworkRxSpeed float64 `json:"networkRxSpeed"` // 网络下载速度 MB/s NetworkTxSpeed float64 `json:"networkTxSpeed"` // 网络上传速度 MB/s - TopProcesses []ProcessInfo `json:"topProcesses"` // Top 5 进程 + TopProcesses []ProcessInfo `json:"topProcesses"` // Top 10 进程 DockerStats DockerStats `json:"dockerStats"` // Docker 统计信息 SystemLogs []string `json:"systemLogs"` // 系统最新日志 } @@ -82,14 +89,14 @@ type ProcessInfo struct { // Docker相关信息(简化版) type DockerStats struct { - Available bool `json:"available"` // Docker是否可用 - Version string `json:"version"` // Docker版本 - Running int `json:"running"` // 运行中的容器数 - Stopped int `json:"stopped"` // 停止的容器数 - ImageCount int `json:"imageCount"` // 镜像数量 - RunningNames []string `json:"runningNames"` // 运行中的容器名列表 - StoppedNames []string `json:"stoppedNames"` // 停止的容器名列表 - ImageNames []string `json:"imageNames"` // 镜像名列表 + Available bool `json:"available"` // Docker是否可用 + Version string `json:"version"` // Docker版本 + Running int `json:"running"` // 运行中的容器数 + Stopped int `json:"stopped"` // 停止的容器数 + ImageCount int `json:"imageCount"` // 镜像数量 + RunningNames []string `json:"runningNames"` // 运行中的容器名列表 + StoppedNames []string `json:"stoppedNames"` // 停止的容器名列表 + ImageNames []string `json:"imageNames"` // 镜像名列表 } // 操作系统相关信息 diff --git a/mengyamonitor-backend/utils.go b/mengyamonitor-backend-client/utils.go similarity index 100% rename from mengyamonitor-backend/utils.go rename to mengyamonitor-backend-client/utils.go diff --git a/mengyamonitor-backend-server/.dockerignore b/mengyamonitor-backend-server/.dockerignore new file mode 100644 index 0000000..80678f2 --- /dev/null +++ b/mengyamonitor-backend-server/.dockerignore @@ -0,0 +1,9 @@ +data/ +.git +.gitignore +*.md +.env +.env.* +*.exe +mengyamonitor-backend-server +__debug_bin diff --git a/mengyamonitor-backend-server/Dockerfile b/mengyamonitor-backend-server/Dockerfile new file mode 100644 index 0000000..2e78b41 --- /dev/null +++ b/mengyamonitor-backend-server/Dockerfile @@ -0,0 +1,21 @@ +# 中心服务:HTTP/WebSocket + gRPC(纯 Go / modernc SQLite,CGO_ENABLED=0) +# 镜像内进程为明文 HTTP;HTTPS 请在宿主机 Nginx 等反代层配置,内网直连本端口即可。 +FROM golang:1.22-alpine AS build +WORKDIR /src +RUN apk add --no-cache git +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /central . + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates tzdata +WORKDIR /app +# 与 config 默认一致; compose 中可覆盖 +ENV HOST=0.0.0.0 +ENV PORT=9393 +ENV GRPC_PORT=9394 +ENV DB_PATH=/app/data/servers.db +COPY --from=build /central /app/central +EXPOSE 9393 9394 +ENTRYPOINT ["/app/central"] diff --git a/mengyamonitor-backend-server/Dockerfile.binary b/mengyamonitor-backend-server/Dockerfile.binary new file mode 100644 index 0000000..a38d22d --- /dev/null +++ b/mengyamonitor-backend-server/Dockerfile.binary @@ -0,0 +1,13 @@ +# 使用本机/CI 已编译好的 Linux amd64 二进制打包镜像(不在 Docker 内 go build) +# 先执行: build-linux-amd64.bat 或 release 目录放置同名文件 +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates tzdata +WORKDIR /app +ENV HOST=0.0.0.0 +ENV PORT=9393 +ENV GRPC_PORT=9394 +ENV DB_PATH=/app/data/servers.db +COPY release/mengyamonitor-central-linux-amd64 /app/central +RUN chmod +x /app/central +EXPOSE 9393 9394 +ENTRYPOINT ["/app/central"] diff --git a/mengyamonitor-backend-server/build-linux-amd64.bat b/mengyamonitor-backend-server/build-linux-amd64.bat new file mode 100644 index 0000000..efa36a0 --- /dev/null +++ b/mengyamonitor-backend-server/build-linux-amd64.bat @@ -0,0 +1,28 @@ +@echo off +setlocal +cd /d "%~dp0" + +where go >nul 2>&1 +if errorlevel 1 ( + echo [错误] 未找到 go,请先安装 Go 并加入 PATH + exit /b 1 +) + +if not exist release mkdir release + +set GOOS=linux +set GOARCH=amd64 +set CGO_ENABLED=0 + +echo 正在编译 Linux amd64 release\mengyamonitor-central-linux-amd64 ... +go build -trimpath -ldflags="-s -w" -o release\mengyamonitor-central-linux-amd64 . +if errorlevel 1 ( + echo [错误] go build 失败 + exit /b 1 +) + +echo 完成: %cd%\release\mengyamonitor-central-linux-amd64 +echo. +echo 可选:用预编译镜像启动(不再在 Docker 里编译) +echo docker compose -f docker-compose.binary.yml up -d --build +exit /b 0 diff --git a/mengyamonitor-backend-server/docker-compose.binary.yml b/mengyamonitor-backend-server/docker-compose.binary.yml new file mode 100644 index 0000000..acbf195 --- /dev/null +++ b/mengyamonitor-backend-server/docker-compose.binary.yml @@ -0,0 +1,20 @@ +# 使用 release/mengyamonitor-central-linux-amd64 打包镜像(先在 Windows 运行 build-linux-amd64.bat) +# 容器内仍是 HTTP:9393 / 9394;HTTPS 仅公网 Nginx 反代层 +services: + central: + build: + context: . + dockerfile: Dockerfile.binary + image: mengyamonitor-central:latest + restart: unless-stopped + ports: + - "9393:9393" + - "9394:9394" + environment: + HOST: "0.0.0.0" + PORT: "9393" + GRPC_PORT: "9394" + ADMIN_TOKEN: ${ADMIN_TOKEN:-shumengya520} + DB_PATH: /app/data/servers.db + volumes: + - ./data:/app/data diff --git a/mengyamonitor-backend-server/docker-compose.yml b/mengyamonitor-backend-server/docker-compose.yml new file mode 100644 index 0000000..74a161b --- /dev/null +++ b/mengyamonitor-backend-server/docker-compose.yml @@ -0,0 +1,23 @@ +# 中心服务在容器内仅为 HTTP(9393 Web/WS、9394 gRPC);TLS 由公网 Nginx 做,与内网无关。 +# +# 本文件:在 Docker 内完整执行 go build(适合无本机 Go 或 CI)。 +# 若已在 Windows 编好 Linux 二进制,避免镜像内再编译:先运行 build-linux-amd64.bat,再 +# docker compose -f docker-compose.binary.yml up -d --build +services: + central: + build: . + image: mengyamonitor-central:latest + restart: unless-stopped + ports: + # 宿主:容器 —— 可按需改为 "127.0.0.1:9393:9393" 仅本机反代 + - "9393:9393" + - "9394:9394" + environment: + HOST: "0.0.0.0" + PORT: "9393" + GRPC_PORT: "9394" + # 务必在宿主设置强口令:export ADMIN_TOKEN=... 或下方 env_file + ADMIN_TOKEN: ${ADMIN_TOKEN:-shumengya520} + DB_PATH: /app/data/servers.db + volumes: + - ./data:/app/data diff --git a/mengyamonitor-backend-server/go.mod b/mengyamonitor-backend-server/go.mod new file mode 100644 index 0000000..1eef026 --- /dev/null +++ b/mengyamonitor-backend-server/go.mod @@ -0,0 +1,47 @@ +module mengyamonitor-backend-server + +go 1.22 + +require ( + github.com/gin-gonic/gin v1.10.0 + github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 + google.golang.org/grpc v1.70.0 + google.golang.org/protobuf v1.35.2 + modernc.org/sqlite v1.34.5 +) + +require ( + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.30.0 // indirect + golang.org/x/net v0.32.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/text v0.21.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect +) diff --git a/mengyamonitor-backend-server/go.sum b/mengyamonitor-backend-server/go.sum new file mode 100644 index 0000000..513e622 --- /dev/null +++ b/mengyamonitor-backend-server/go.sum @@ -0,0 +1,149 @@ +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U= +go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg= +go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M= +go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8= +go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4= +go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU= +go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU= +go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ= +go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM= +go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.30.0 h1:RwoQn3GkWiMkzlX562cLB7OxWvjH1L8xutO2WoJcRoY= +golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI= +golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a h1:hgh8P4EuoxpsuKMXX/To36nOFD7vixReXgn8lPGnt+o= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU= +google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ= +google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw= +google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= +google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g= +modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/mengyamonitor-backend-server/internal/config/config.go b/mengyamonitor-backend-server/internal/config/config.go new file mode 100644 index 0000000..3d8623a --- /dev/null +++ b/mengyamonitor-backend-server/internal/config/config.go @@ -0,0 +1,30 @@ +package config + +import ( + "os" +) + +type Config struct { + Host string + Port string + GRPCPort string + AdminToken string + DBPath string +} + +func Load() *Config { + return &Config{ + Host: getenv("HOST", "0.0.0.0"), + Port: getenv("PORT", "9393"), + GRPCPort: getenv("GRPC_PORT", "9394"), + AdminToken: getenv("ADMIN_TOKEN", "shumengya520"), + DBPath: getenv("DB_PATH", "./data/servers.db"), + } +} + +func getenv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/mengyamonitor-backend-server/internal/gen/mengyav1/agent.pb.go b/mengyamonitor-backend-server/internal/gen/mengyav1/agent.pb.go new file mode 100644 index 0000000..13b78e8 --- /dev/null +++ b/mengyamonitor-backend-server/internal/gen/mengyav1/agent.pb.go @@ -0,0 +1,492 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.5 +// protoc v5.28.3 +// source: mengya/v1/agent.proto + +package mengyav1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type AgentMessage struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Payload: + // + // *AgentMessage_Hello + // *AgentMessage_Heartbeat + // *AgentMessage_Metrics + Payload isAgentMessage_Payload `protobuf_oneof:"payload"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AgentMessage) Reset() { + *x = AgentMessage{} + mi := &file_mengya_v1_agent_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AgentMessage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentMessage) ProtoMessage() {} + +func (x *AgentMessage) ProtoReflect() protoreflect.Message { + mi := &file_mengya_v1_agent_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentMessage.ProtoReflect.Descriptor instead. +func (*AgentMessage) Descriptor() ([]byte, []int) { + return file_mengya_v1_agent_proto_rawDescGZIP(), []int{0} +} + +func (x *AgentMessage) GetPayload() isAgentMessage_Payload { + if x != nil { + return x.Payload + } + return nil +} + +func (x *AgentMessage) GetHello() *Hello { + if x != nil { + if x, ok := x.Payload.(*AgentMessage_Hello); ok { + return x.Hello + } + } + return nil +} + +func (x *AgentMessage) GetHeartbeat() *Heartbeat { + if x != nil { + if x, ok := x.Payload.(*AgentMessage_Heartbeat); ok { + return x.Heartbeat + } + } + return nil +} + +func (x *AgentMessage) GetMetrics() *MetricsReport { + if x != nil { + if x, ok := x.Payload.(*AgentMessage_Metrics); ok { + return x.Metrics + } + } + return nil +} + +type isAgentMessage_Payload interface { + isAgentMessage_Payload() +} + +type AgentMessage_Hello struct { + Hello *Hello `protobuf:"bytes,1,opt,name=hello,proto3,oneof"` +} + +type AgentMessage_Heartbeat struct { + Heartbeat *Heartbeat `protobuf:"bytes,2,opt,name=heartbeat,proto3,oneof"` +} + +type AgentMessage_Metrics struct { + Metrics *MetricsReport `protobuf:"bytes,3,opt,name=metrics,proto3,oneof"` +} + +func (*AgentMessage_Hello) isAgentMessage_Payload() {} + +func (*AgentMessage_Heartbeat) isAgentMessage_Payload() {} + +func (*AgentMessage_Metrics) isAgentMessage_Payload() {} + +type Hello struct { + state protoimpl.MessageState `protogen:"open.v1"` + ServerId string `protobuf:"bytes,1,opt,name=server_id,json=serverId,proto3" json:"server_id,omitempty"` + AgentKey string `protobuf:"bytes,2,opt,name=agent_key,json=agentKey,proto3" json:"agent_key,omitempty"` + Hostname string `protobuf:"bytes,3,opt,name=hostname,proto3" json:"hostname,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Hello) Reset() { + *x = Hello{} + mi := &file_mengya_v1_agent_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Hello) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Hello) ProtoMessage() {} + +func (x *Hello) ProtoReflect() protoreflect.Message { + mi := &file_mengya_v1_agent_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Hello.ProtoReflect.Descriptor instead. +func (*Hello) Descriptor() ([]byte, []int) { + return file_mengya_v1_agent_proto_rawDescGZIP(), []int{1} +} + +func (x *Hello) GetServerId() string { + if x != nil { + return x.ServerId + } + return "" +} + +func (x *Hello) GetAgentKey() string { + if x != nil { + return x.AgentKey + } + return "" +} + +func (x *Hello) GetHostname() string { + if x != nil { + return x.Hostname + } + return "" +} + +type Heartbeat struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Heartbeat) Reset() { + *x = Heartbeat{} + mi := &file_mengya_v1_agent_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Heartbeat) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Heartbeat) ProtoMessage() {} + +func (x *Heartbeat) ProtoReflect() protoreflect.Message { + mi := &file_mengya_v1_agent_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Heartbeat.ProtoReflect.Descriptor instead. +func (*Heartbeat) Descriptor() ([]byte, []int) { + return file_mengya_v1_agent_proto_rawDescGZIP(), []int{2} +} + +type MetricsReport struct { + state protoimpl.MessageState `protogen:"open.v1"` + PayloadJson string `protobuf:"bytes,1,opt,name=payload_json,json=payloadJson,proto3" json:"payload_json,omitempty"` + CollectedAtUnixMs int64 `protobuf:"varint,2,opt,name=collected_at_unix_ms,json=collectedAtUnixMs,proto3" json:"collected_at_unix_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MetricsReport) Reset() { + *x = MetricsReport{} + mi := &file_mengya_v1_agent_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MetricsReport) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MetricsReport) ProtoMessage() {} + +func (x *MetricsReport) ProtoReflect() protoreflect.Message { + mi := &file_mengya_v1_agent_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MetricsReport.ProtoReflect.Descriptor instead. +func (*MetricsReport) Descriptor() ([]byte, []int) { + return file_mengya_v1_agent_proto_rawDescGZIP(), []int{3} +} + +func (x *MetricsReport) GetPayloadJson() string { + if x != nil { + return x.PayloadJson + } + return "" +} + +func (x *MetricsReport) GetCollectedAtUnixMs() int64 { + if x != nil { + return x.CollectedAtUnixMs + } + return 0 +} + +type ControlMessage struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Payload: + // + // *ControlMessage_Ack + Payload isControlMessage_Payload `protobuf_oneof:"payload"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ControlMessage) Reset() { + *x = ControlMessage{} + mi := &file_mengya_v1_agent_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ControlMessage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ControlMessage) ProtoMessage() {} + +func (x *ControlMessage) ProtoReflect() protoreflect.Message { + mi := &file_mengya_v1_agent_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ControlMessage.ProtoReflect.Descriptor instead. +func (*ControlMessage) Descriptor() ([]byte, []int) { + return file_mengya_v1_agent_proto_rawDescGZIP(), []int{4} +} + +func (x *ControlMessage) GetPayload() isControlMessage_Payload { + if x != nil { + return x.Payload + } + return nil +} + +func (x *ControlMessage) GetAck() *Ack { + if x != nil { + if x, ok := x.Payload.(*ControlMessage_Ack); ok { + return x.Ack + } + } + return nil +} + +type isControlMessage_Payload interface { + isControlMessage_Payload() +} + +type ControlMessage_Ack struct { + Ack *Ack `protobuf:"bytes,1,opt,name=ack,proto3,oneof"` +} + +func (*ControlMessage_Ack) isControlMessage_Payload() {} + +type Ack struct { + state protoimpl.MessageState `protogen:"open.v1"` + Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Ack) Reset() { + *x = Ack{} + mi := &file_mengya_v1_agent_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Ack) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Ack) ProtoMessage() {} + +func (x *Ack) ProtoReflect() protoreflect.Message { + mi := &file_mengya_v1_agent_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Ack.ProtoReflect.Descriptor instead. +func (*Ack) Descriptor() ([]byte, []int) { + return file_mengya_v1_agent_proto_rawDescGZIP(), []int{5} +} + +func (x *Ack) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +var File_mengya_v1_agent_proto protoreflect.FileDescriptor + +var file_mengya_v1_agent_proto_rawDesc = string([]byte{ + 0x0a, 0x15, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2f, 0x76, 0x31, 0x2f, 0x61, 0x67, 0x65, 0x6e, + 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x09, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2e, + 0x76, 0x31, 0x22, 0xaf, 0x01, 0x0a, 0x0c, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x4d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x12, 0x28, 0x0a, 0x05, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x48, + 0x65, 0x6c, 0x6c, 0x6f, 0x48, 0x00, 0x52, 0x05, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x12, 0x34, 0x0a, + 0x09, 0x68, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x14, 0x2e, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x48, 0x65, 0x61, + 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x48, 0x00, 0x52, 0x09, 0x68, 0x65, 0x61, 0x72, 0x74, 0x62, + 0x65, 0x61, 0x74, 0x12, 0x34, 0x0a, 0x07, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2e, 0x76, 0x31, + 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x48, 0x00, + 0x52, 0x07, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x42, 0x09, 0x0a, 0x07, 0x70, 0x61, 0x79, + 0x6c, 0x6f, 0x61, 0x64, 0x22, 0x5d, 0x0a, 0x05, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x12, 0x1b, 0x0a, + 0x09, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x08, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x61, 0x67, + 0x65, 0x6e, 0x74, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x61, + 0x67, 0x65, 0x6e, 0x74, 0x4b, 0x65, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e, + 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e, + 0x61, 0x6d, 0x65, 0x22, 0x0b, 0x0a, 0x09, 0x48, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, + 0x22, 0x63, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x52, 0x65, 0x70, 0x6f, 0x72, + 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x5f, 0x6a, 0x73, 0x6f, + 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, + 0x4a, 0x73, 0x6f, 0x6e, 0x12, 0x2f, 0x0a, 0x14, 0x63, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x65, + 0x64, 0x5f, 0x61, 0x74, 0x5f, 0x75, 0x6e, 0x69, 0x78, 0x5f, 0x6d, 0x73, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x03, 0x52, 0x11, 0x63, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x65, 0x64, 0x41, 0x74, 0x55, + 0x6e, 0x69, 0x78, 0x4d, 0x73, 0x22, 0x3f, 0x0a, 0x0e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, + 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x22, 0x0a, 0x03, 0x61, 0x63, 0x6b, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2e, 0x76, 0x31, + 0x2e, 0x41, 0x63, 0x6b, 0x48, 0x00, 0x52, 0x03, 0x61, 0x63, 0x6b, 0x42, 0x09, 0x0a, 0x07, 0x70, + 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x22, 0x1f, 0x0a, 0x03, 0x41, 0x63, 0x6b, 0x12, 0x18, 0x0a, + 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, + 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x32, 0x50, 0x0a, 0x0b, 0x41, 0x67, 0x65, 0x6e, 0x74, + 0x49, 0x6e, 0x67, 0x65, 0x73, 0x74, 0x12, 0x41, 0x0a, 0x07, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, + 0x74, 0x12, 0x17, 0x2e, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x67, + 0x65, 0x6e, 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x19, 0x2e, 0x6d, 0x65, 0x6e, + 0x67, 0x79, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x4d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x28, 0x01, 0x30, 0x01, 0x42, 0x3d, 0x5a, 0x3b, 0x6d, 0x65, 0x6e, + 0x67, 0x79, 0x61, 0x6d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x2d, 0x62, 0x61, 0x63, 0x6b, 0x65, + 0x6e, 0x64, 0x2d, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, + 0x61, 0x6c, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x76, 0x31, 0x3b, + 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +}) + +var ( + file_mengya_v1_agent_proto_rawDescOnce sync.Once + file_mengya_v1_agent_proto_rawDescData []byte +) + +func file_mengya_v1_agent_proto_rawDescGZIP() []byte { + file_mengya_v1_agent_proto_rawDescOnce.Do(func() { + file_mengya_v1_agent_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_mengya_v1_agent_proto_rawDesc), len(file_mengya_v1_agent_proto_rawDesc))) + }) + return file_mengya_v1_agent_proto_rawDescData +} + +var file_mengya_v1_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_mengya_v1_agent_proto_goTypes = []any{ + (*AgentMessage)(nil), // 0: mengya.v1.AgentMessage + (*Hello)(nil), // 1: mengya.v1.Hello + (*Heartbeat)(nil), // 2: mengya.v1.Heartbeat + (*MetricsReport)(nil), // 3: mengya.v1.MetricsReport + (*ControlMessage)(nil), // 4: mengya.v1.ControlMessage + (*Ack)(nil), // 5: mengya.v1.Ack +} +var file_mengya_v1_agent_proto_depIdxs = []int32{ + 1, // 0: mengya.v1.AgentMessage.hello:type_name -> mengya.v1.Hello + 2, // 1: mengya.v1.AgentMessage.heartbeat:type_name -> mengya.v1.Heartbeat + 3, // 2: mengya.v1.AgentMessage.metrics:type_name -> mengya.v1.MetricsReport + 5, // 3: mengya.v1.ControlMessage.ack:type_name -> mengya.v1.Ack + 0, // 4: mengya.v1.AgentIngest.Connect:input_type -> mengya.v1.AgentMessage + 4, // 5: mengya.v1.AgentIngest.Connect:output_type -> mengya.v1.ControlMessage + 5, // [5:6] is the sub-list for method output_type + 4, // [4:5] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name +} + +func init() { file_mengya_v1_agent_proto_init() } +func file_mengya_v1_agent_proto_init() { + if File_mengya_v1_agent_proto != nil { + return + } + file_mengya_v1_agent_proto_msgTypes[0].OneofWrappers = []any{ + (*AgentMessage_Hello)(nil), + (*AgentMessage_Heartbeat)(nil), + (*AgentMessage_Metrics)(nil), + } + file_mengya_v1_agent_proto_msgTypes[4].OneofWrappers = []any{ + (*ControlMessage_Ack)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_mengya_v1_agent_proto_rawDesc), len(file_mengya_v1_agent_proto_rawDesc)), + NumEnums: 0, + NumMessages: 6, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_mengya_v1_agent_proto_goTypes, + DependencyIndexes: file_mengya_v1_agent_proto_depIdxs, + MessageInfos: file_mengya_v1_agent_proto_msgTypes, + }.Build() + File_mengya_v1_agent_proto = out.File + file_mengya_v1_agent_proto_goTypes = nil + file_mengya_v1_agent_proto_depIdxs = nil +} diff --git a/mengyamonitor-backend-server/internal/gen/mengyav1/agent_grpc.pb.go b/mengyamonitor-backend-server/internal/gen/mengyav1/agent_grpc.pb.go new file mode 100644 index 0000000..d5aa41a --- /dev/null +++ b/mengyamonitor-backend-server/internal/gen/mengyav1/agent_grpc.pb.go @@ -0,0 +1,119 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc v5.28.3 +// source: mengya/v1/agent.proto + +package mengyav1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + AgentIngest_Connect_FullMethodName = "/mengya.v1.AgentIngest/Connect" +) + +// AgentIngestClient is the client API for AgentIngest service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// AgentIngest: 采集端出站连接,双向流上报指标。 +type AgentIngestClient interface { + Connect(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[AgentMessage, ControlMessage], error) +} + +type agentIngestClient struct { + cc grpc.ClientConnInterface +} + +func NewAgentIngestClient(cc grpc.ClientConnInterface) AgentIngestClient { + return &agentIngestClient{cc} +} + +func (c *agentIngestClient) Connect(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[AgentMessage, ControlMessage], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &AgentIngest_ServiceDesc.Streams[0], AgentIngest_Connect_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[AgentMessage, ControlMessage]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type AgentIngest_ConnectClient = grpc.BidiStreamingClient[AgentMessage, ControlMessage] + +// AgentIngestServer is the server API for AgentIngest service. +// All implementations must embed UnimplementedAgentIngestServer +// for forward compatibility. +// +// AgentIngest: 采集端出站连接,双向流上报指标。 +type AgentIngestServer interface { + Connect(grpc.BidiStreamingServer[AgentMessage, ControlMessage]) error + mustEmbedUnimplementedAgentIngestServer() +} + +// UnimplementedAgentIngestServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedAgentIngestServer struct{} + +func (UnimplementedAgentIngestServer) Connect(grpc.BidiStreamingServer[AgentMessage, ControlMessage]) error { + return status.Errorf(codes.Unimplemented, "method Connect not implemented") +} +func (UnimplementedAgentIngestServer) mustEmbedUnimplementedAgentIngestServer() {} +func (UnimplementedAgentIngestServer) testEmbeddedByValue() {} + +// UnsafeAgentIngestServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to AgentIngestServer will +// result in compilation errors. +type UnsafeAgentIngestServer interface { + mustEmbedUnimplementedAgentIngestServer() +} + +func RegisterAgentIngestServer(s grpc.ServiceRegistrar, srv AgentIngestServer) { + // If the following call pancis, it indicates UnimplementedAgentIngestServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&AgentIngest_ServiceDesc, srv) +} + +func _AgentIngest_Connect_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(AgentIngestServer).Connect(&grpc.GenericServerStream[AgentMessage, ControlMessage]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type AgentIngest_ConnectServer = grpc.BidiStreamingServer[AgentMessage, ControlMessage] + +// AgentIngest_ServiceDesc is the grpc.ServiceDesc for AgentIngest service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var AgentIngest_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "mengya.v1.AgentIngest", + HandlerType: (*AgentIngestServer)(nil), + Methods: []grpc.MethodDesc{}, + Streams: []grpc.StreamDesc{ + { + StreamName: "Connect", + Handler: _AgentIngest_Connect_Handler, + ServerStreams: true, + ClientStreams: true, + }, + }, + Metadata: "mengya/v1/agent.proto", +} diff --git a/mengyamonitor-backend-server/internal/grpcrun/grpcrun.go b/mengyamonitor-backend-server/internal/grpcrun/grpcrun.go new file mode 100644 index 0000000..0c0c13f --- /dev/null +++ b/mengyamonitor-backend-server/internal/grpcrun/grpcrun.go @@ -0,0 +1,22 @@ +package grpcrun + +import ( + "fmt" + "net" + + "mengyamonitor-backend-server/internal/gen/mengyav1" + "mengyamonitor-backend-server/internal/ingest" + + "google.golang.org/grpc" +) + +// Serve starts a blocking gRPC server for agent ingest. +func Serve(listenAddr string, srv *ingest.Server) error { + lis, err := net.Listen("tcp", listenAddr) + if err != nil { + return fmt.Errorf("listen: %w", err) + } + g := grpc.NewServer() + mengyav1.RegisterAgentIngestServer(g, srv) + return g.Serve(lis) +} diff --git a/mengyamonitor-backend-server/internal/handler/admin.go b/mengyamonitor-backend-server/internal/handler/admin.go new file mode 100644 index 0000000..a301fdc --- /dev/null +++ b/mengyamonitor-backend-server/internal/handler/admin.go @@ -0,0 +1,100 @@ +package handler + +import ( + "crypto/subtle" + "database/sql" + "errors" + "net/http" + + "mengyamonitor-backend-server/internal/config" + "mengyamonitor-backend-server/internal/model" + "mengyamonitor-backend-server/internal/store" + + "github.com/gin-gonic/gin" +) + +type Admin struct { + Store *store.Store + Cfg *config.Config +} + +func (h *Admin) Auth(c *gin.Context) { + var in model.AuthInput + if err := c.ShouldBindJSON(&in); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"}) + return + } + if subtle.ConstantTimeCompare([]byte(in.Token), []byte(h.Cfg.AdminToken)) != 1 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func (h *Admin) ListServers(c *gin.Context) { + list, err := h.Store.ListAll(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"data": list}) +} + +func (h *Admin) CreateServer(c *gin.Context) { + var in model.CreateServerInput + if err := c.ShouldBindJSON(&in); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"}) + return + } + s, err := h.Store.Create(c.Request.Context(), in) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{"data": s}) +} + +func (h *Admin) UpdateServer(c *gin.Context) { + id := c.Param("id") + var in model.UpdateServerInput + if err := c.ShouldBindJSON(&in); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"}) + return + } + s, err := h.Store.Update(c.Request.Context(), id, in) + if errors.Is(err, sql.ErrNoRows) { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"data": s}) +} + +func (h *Admin) DeleteServer(c *gin.Context) { + id := c.Param("id") + if err := h.Store.Delete(c.Request.Context(), id); err != nil { + if errors.Is(err, sql.ErrNoRows) { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func (h *Admin) Reorder(c *gin.Context) { + var in model.ReorderInput + if err := c.ShouldBindJSON(&in); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"}) + return + } + if err := h.Store.Reorder(c.Request.Context(), in.IDs); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} diff --git a/mengyamonitor-backend-server/internal/handler/public.go b/mengyamonitor-backend-server/internal/handler/public.go new file mode 100644 index 0000000..e1bc0c4 --- /dev/null +++ b/mengyamonitor-backend-server/internal/handler/public.go @@ -0,0 +1,70 @@ +package handler + +import ( + "net/http" + "time" + + "mengyamonitor-backend-server/internal/model" + "mengyamonitor-backend-server/internal/store" + + "github.com/gin-gonic/gin" +) + +type Public struct { + Store *store.Store +} + +// publicServer hides agent_key from unauthenticated API consumers. +type publicServer struct { + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + Enabled bool `json:"enabled"` + SortOrder int `json:"sortOrder"` + CreatedAt time.Time `json:"createdAt,omitempty"` +} + +func toPublicServers(in []model.MonitoredServer) []publicServer { + out := make([]publicServer, 0, len(in)) + for _, s := range in { + out = append(out, publicServer{ + ID: s.ID, Name: s.Name, URL: s.URL, Enabled: s.Enabled, + SortOrder: s.SortOrder, CreatedAt: s.CreatedAt, + }) + } + return out +} + +func (h *Public) Health(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} + +func (h *Public) ListServers(c *gin.Context) { + list, err := h.Store.ListEnabled(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"data": toPublicServers(list)}) +} + +func (h *Public) Root(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "service": "萌芽监控 — 中心服务", + "version": "1.0.0", + "public": []string{ + "GET /api/health", + "GET /api/servers", + "GET /api/ws/monitor — WebSocket 指标快照", + "gRPC AgentIngest — 见环境变量 GRPC_PORT", + }, + "admin": []string{ + "POST /api/admin/auth", + "GET /api/admin/servers", + "POST /api/admin/servers", + "PUT /api/admin/servers/:id", + "DELETE /api/admin/servers/:id", + "PUT /api/admin/servers/reorder", + }, + }) +} diff --git a/mengyamonitor-backend-server/internal/handler/ws.go b/mengyamonitor-backend-server/internal/handler/ws.go new file mode 100644 index 0000000..fcf92db --- /dev/null +++ b/mengyamonitor-backend-server/internal/handler/ws.go @@ -0,0 +1,50 @@ +package handler + +import ( + "log" + "net/http" + "time" + + "mengyamonitor-backend-server/internal/hub" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +var wsUpgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { + return true + }, +} + +// MonitorWebSocket upgrades to WebSocket and streams hub snapshots. +func MonitorWebSocket(h *hub.Hub) gin.HandlerFunc { + return func(c *gin.Context) { + conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + log.Printf("ws: upgrade: %v", err) + return + } + defer h.Unregister(conn) + snap, err := h.BuildSnapshotMessage() + if err != nil { + _ = conn.Close() + return + } + _ = conn.SetWriteDeadline(time.Now().Add(15 * time.Second)) + if err := conn.WriteMessage(websocket.TextMessage, snap); err != nil { + _ = conn.Close() + return + } + h.AddSubscriber(conn) + for { + _ = conn.SetReadDeadline(time.Now().Add(120 * time.Second)) + _, _, err := conn.ReadMessage() + if err != nil { + break + } + } + } +} diff --git a/mengyamonitor-backend-server/internal/hub/hub.go b/mengyamonitor-backend-server/internal/hub/hub.go new file mode 100644 index 0000000..94c33cb --- /dev/null +++ b/mengyamonitor-backend-server/internal/hub/hub.go @@ -0,0 +1,108 @@ +package hub + +import ( + "encoding/json" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +// WSMessage is sent to browsers (matches frontend ServerStatus shape). +type WSMessage struct { + Type string `json:"type"` + Data map[string]json.RawMessage `json:"data"` +} + +type Hub struct { + mu sync.RWMutex + // serverId -> latest metrics JSON object (not wrapped) + metrics map[string]json.RawMessage + + subsMu sync.Mutex + // subscribers for broadcast + conns map[*websocket.Conn]struct{} +} + +func New() *Hub { + return &Hub{ + metrics: make(map[string]json.RawMessage), + conns: make(map[*websocket.Conn]struct{}), + } +} + +func (h *Hub) SetMetrics(serverID string, metricsJSON []byte) { + h.mu.Lock() + if len(metricsJSON) == 0 { + delete(h.metrics, serverID) + } else { + h.metrics[serverID] = json.RawMessage(append([]byte(nil), metricsJSON...)) + } + h.mu.Unlock() +} + +func (h *Hub) snapshotData() map[string]json.RawMessage { + h.mu.RLock() + defer h.mu.RUnlock() + out := make(map[string]json.RawMessage, len(h.metrics)) + now := time.Now().UnixMilli() + for id, raw := range h.metrics { + // Wrap as ServerStatus for frontend compatibility + wrap := map[string]any{ + "serverId": id, + "online": true, + "metrics": json.RawMessage(raw), + "lastUpdate": now, + } + b, err := json.Marshal(wrap) + if err != nil { + continue + } + out[id] = b + } + return out +} + +func (h *Hub) BuildSnapshotMessage() ([]byte, error) { + msg := WSMessage{ + Type: "snapshot", + Data: h.snapshotData(), + } + return json.Marshal(msg) +} + +// AddSubscriber registers for broadcast fan-out (call after sending the initial snapshot). +func (h *Hub) AddSubscriber(c *websocket.Conn) { + h.subsMu.Lock() + h.conns[c] = struct{}{} + h.subsMu.Unlock() +} + +func (h *Hub) Unregister(c *websocket.Conn) { + h.subsMu.Lock() + delete(h.conns, c) + h.subsMu.Unlock() +} + +// BroadcastSnapshot pushes full snapshot to all subscribers. +func (h *Hub) BroadcastSnapshot() { + body, err := h.BuildSnapshotMessage() + if err != nil { + return + } + h.subsMu.Lock() + defer h.subsMu.Unlock() + for c := range h.conns { + _ = c.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if err := c.WriteMessage(websocket.TextMessage, body); err != nil { + _ = c.Close() + delete(h.conns, c) + } + } +} + +// NotifyMetricsUpdate should be called after SetMetrics to push to browsers. +func (h *Hub) NotifyMetricsUpdate(serverID string, metricsJSON []byte) { + h.SetMetrics(serverID, metricsJSON) + h.BroadcastSnapshot() +} diff --git a/mengyamonitor-backend-server/internal/ingest/server.go b/mengyamonitor-backend-server/internal/ingest/server.go new file mode 100644 index 0000000..318d8f5 --- /dev/null +++ b/mengyamonitor-backend-server/internal/ingest/server.go @@ -0,0 +1,82 @@ +package ingest + +import ( + "database/sql" + "errors" + "io" + "log" + + "mengyamonitor-backend-server/internal/gen/mengyav1" + "mengyamonitor-backend-server/internal/hub" + "mengyamonitor-backend-server/internal/store" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type Server struct { + mengyav1.UnimplementedAgentIngestServer + Store *store.Store + Hub *hub.Hub +} + +func (s *Server) Connect(stream grpc.BidiStreamingServer[mengyav1.AgentMessage, mengyav1.ControlMessage]) error { + var authedID string + for { + msg, err := stream.Recv() + if err == io.EOF { + return nil + } + if err != nil { + return err + } + + ctx := stream.Context() + + switch { + case msg.GetHello() != nil: + h := msg.GetHello() + srv, err := s.Store.VerifyAgent(ctx, h.GetServerId(), h.GetAgentKey()) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + log.Printf("ingest: auth failed server_id=%q: no such id (SERVER_ID must be the UUID from admin table, not the display name)", h.GetServerId()) + } else { + log.Printf("ingest: auth failed server_id=%s: %v", h.GetServerId(), err) + } + return status.Errorf(codes.Unauthenticated, "invalid credentials") + } + authedID = srv.ID + _ = stream.Send(&mengyav1.ControlMessage{ + Payload: &mengyav1.ControlMessage_Ack{ + Ack: &mengyav1.Ack{Message: "hello_ok"}, + }, + }) + + case msg.GetHeartbeat() != nil: + if authedID == "" { + continue + } + _ = stream.Send(&mengyav1.ControlMessage{ + Payload: &mengyav1.ControlMessage_Ack{ + Ack: &mengyav1.Ack{Message: "pong"}, + }, + }) + + case msg.GetMetrics() != nil: + if authedID == "" { + return status.Errorf(codes.FailedPrecondition, "send hello first") + } + m := msg.GetMetrics() + if len(m.GetPayloadJson()) == 0 { + continue + } + s.Hub.NotifyMetricsUpdate(authedID, []byte(m.GetPayloadJson())) + _ = stream.Send(&mengyav1.ControlMessage{ + Payload: &mengyav1.ControlMessage_Ack{ + Ack: &mengyav1.Ack{Message: "metrics_ok"}, + }, + }) + } + } +} diff --git a/mengyamonitor-backend-server/internal/middleware/admin.go b/mengyamonitor-backend-server/internal/middleware/admin.go new file mode 100644 index 0000000..d7ff37e --- /dev/null +++ b/mengyamonitor-backend-server/internal/middleware/admin.go @@ -0,0 +1,23 @@ +package middleware + +import ( + "crypto/subtle" + "net/http" + + "mengyamonitor-backend-server/internal/config" + + "github.com/gin-gonic/gin" +) + +// AdminToken rejects requests without a valid X-Admin-Token header. +func AdminToken(cfg *config.Config) gin.HandlerFunc { + tok := []byte(cfg.AdminToken) + return func(c *gin.Context) { + given := []byte(c.GetHeader("X-Admin-Token")) + if subtle.ConstantTimeCompare(given, tok) != 1 { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + c.Next() + } +} diff --git a/mengyamonitor-backend-server/internal/model/server.go b/mengyamonitor-backend-server/internal/model/server.go new file mode 100644 index 0000000..7ac08c8 --- /dev/null +++ b/mengyamonitor-backend-server/internal/model/server.go @@ -0,0 +1,35 @@ +package model + +import "time" + +// MonitoredServer is persisted and exposed to the frontend display. +type MonitoredServer struct { + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + Enabled bool `json:"enabled"` + SortOrder int `json:"sortOrder"` + AgentKey string `json:"agentKey,omitempty"` + CreatedAt time.Time `json:"createdAt,omitempty"` +} + +type CreateServerInput struct { + Name string `json:"name" binding:"required"` + URL string `json:"url"` + Enabled *bool `json:"enabled"` +} + +type UpdateServerInput struct { + Name string `json:"name"` + URL *string `json:"url"` + Enabled *bool `json:"enabled"` + AgentKey *string `json:"agentKey"` +} + +type ReorderInput struct { + IDs []string `json:"ids" binding:"required"` +} + +type AuthInput struct { + Token string `json:"token" binding:"required"` +} diff --git a/mengyamonitor-backend-server/internal/router/router.go b/mengyamonitor-backend-server/internal/router/router.go new file mode 100644 index 0000000..4a307f4 --- /dev/null +++ b/mengyamonitor-backend-server/internal/router/router.go @@ -0,0 +1,71 @@ +package router + +import ( + "net/http" + + "mengyamonitor-backend-server/internal/config" + "mengyamonitor-backend-server/internal/handler" + "mengyamonitor-backend-server/internal/hub" + "mengyamonitor-backend-server/internal/middleware" + "mengyamonitor-backend-server/internal/store" + + "github.com/gin-gonic/gin" +) + +func New(cfg *config.Config, st *store.Store, h *hub.Hub) *gin.Engine { + gin.SetMode(gin.ReleaseMode) + r := gin.New() + r.Use(gin.Logger(), gin.Recovery()) + + // 公网宽松 CORS:任意 Origin + 透传浏览器请求的 Header(预检) + r.Use(func(c *gin.Context) { + h := c.Writer.Header() + origin := c.GetHeader("Origin") + if origin != "" { + h.Set("Access-Control-Allow-Origin", origin) + h.Set("Access-Control-Allow-Credentials", "true") + } else { + h.Set("Access-Control-Allow-Origin", "*") + } + h.Set("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS") + if reqH := c.GetHeader("Access-Control-Request-Headers"); reqH != "" { + h.Set("Access-Control-Allow-Headers", reqH) + } else { + h.Set("Access-Control-Allow-Headers", "Content-Type, X-Admin-Token, Authorization, Accept, Origin, X-Requested-With") + } + h.Set("Access-Control-Max-Age", "86400") + if c.Request.Method == http.MethodOptions { + c.AbortWithStatus(http.StatusNoContent) + return + } + c.Next() + }) + + pub := &handler.Public{Store: st} + r.GET("/", pub.Root) + r.GET("/api/health", pub.Health) + r.GET("/api/servers", pub.ListServers) + r.GET("/api/ws/monitor", handler.MonitorWebSocket(h)) + + adm := &handler.Admin{Store: st, Cfg: cfg} + r.POST("/api/admin/auth", adm.Auth) + adminG := r.Group("/api/admin") + adminG.Use(middleware.AdminToken(cfg)) + { + adminG.GET("/servers", adm.ListServers) + adminG.POST("/servers", adm.CreateServer) + adminG.PUT("/servers/reorder", adm.Reorder) + adminG.PUT("/servers/:id", adm.UpdateServer) + adminG.DELETE("/servers/:id", adm.DeleteServer) + } + + r.NoRoute(func(c *gin.Context) { + c.JSON(http.StatusNotFound, gin.H{ + "error": "not_found", + "path": c.Request.URL.Path, + "method": c.Request.Method, + }) + }) + + return r +} diff --git a/mengyamonitor-backend-server/internal/store/sqlite.go b/mengyamonitor-backend-server/internal/store/sqlite.go new file mode 100644 index 0000000..5fffb1c --- /dev/null +++ b/mengyamonitor-backend-server/internal/store/sqlite.go @@ -0,0 +1,307 @@ +package store + +import ( + "context" + "crypto/subtle" + "database/sql" + "errors" + "os" + "path/filepath" + "strings" + "time" + + "mengyamonitor-backend-server/internal/model" + + "github.com/google/uuid" + _ "modernc.org/sqlite" +) + +type Store struct { + db *sql.DB +} + +func Open(dbPath string) (*Store, error) { + dir := filepath.Dir(dbPath) + if dir != "." && dir != "" { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + } + db, err := sql.Open("sqlite", dbPath+"?_pragma=foreign_keys(1)") + if err != nil { + return nil, err + } + if err := db.Ping(); err != nil { + _ = db.Close() + return nil, err + } + s := &Store{db: db} + if err := s.migrate(); err != nil { + _ = db.Close() + return nil, err + } + return s, nil +} + +func (s *Store) Close() error { + return s.db.Close() +} + +func (s *Store) migrate() error { + const ddl = ` +CREATE TABLE IF NOT EXISTS servers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + url TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_servers_sort ON servers(sort_order); +` + if _, err := s.db.Exec(ddl); err != nil { + return err + } + return s.ensureAgentKeyColumn() +} + +func (s *Store) ensureAgentKeyColumn() error { + rows, err := s.db.Query(`PRAGMA table_info(servers)`) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var cid int + var name, typ string + var notnull, pk int + var dflt sql.NullString + if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil { + return err + } + if name == "agent_key" { + return nil + } + } + if err := rows.Err(); err != nil { + return err + } + if _, err = s.db.Exec(`ALTER TABLE servers ADD COLUMN agent_key TEXT`); err != nil { + return err + } + // Backfill keys for existing rows so gRPC can be enabled without manual SQL. + bRows, err := s.db.Query(`SELECT id FROM servers WHERE agent_key IS NULL OR agent_key = ''`) + if err != nil { + return err + } + var ids []string + for bRows.Next() { + var id string + if err := bRows.Scan(&id); err != nil { + _ = bRows.Close() + return err + } + ids = append(ids, id) + } + _ = bRows.Close() + for _, id := range ids { + if _, err := s.db.Exec(`UPDATE servers SET agent_key = ? WHERE id = ?`, uuid.NewString(), id); err != nil { + return err + } + } + return nil +} + +func (s *Store) ListEnabled(ctx context.Context) ([]model.MonitoredServer, error) { + rows, err := s.db.QueryContext(ctx, ` +SELECT id, name, url, enabled, sort_order, agent_key, created_at +FROM servers WHERE enabled = 1 ORDER BY sort_order ASC, created_at ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + return scanServers(rows) +} + +func (s *Store) ListAll(ctx context.Context) ([]model.MonitoredServer, error) { + rows, err := s.db.QueryContext(ctx, ` +SELECT id, name, url, enabled, sort_order, agent_key, created_at +FROM servers ORDER BY sort_order ASC, created_at ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + return scanServers(rows) +} + +func scanServers(rows *sql.Rows) ([]model.MonitoredServer, error) { + out := make([]model.MonitoredServer, 0) + for rows.Next() { + var m model.MonitoredServer + var enabled int + var created, agKey sql.NullString + if err := rows.Scan(&m.ID, &m.Name, &m.URL, &enabled, &m.SortOrder, &agKey, &created); err != nil { + return nil, err + } + m.Enabled = enabled != 0 + if agKey.Valid { + m.AgentKey = agKey.String + } + if t, err := time.Parse(time.RFC3339Nano, created.String); err == nil { + m.CreatedAt = t + } + out = append(out, m) + } + return out, rows.Err() +} + +func (s *Store) Create(ctx context.Context, in model.CreateServerInput) (*model.MonitoredServer, error) { + name := strings.TrimSpace(in.Name) + urlStr := normalizeURL(strings.TrimSpace(in.URL)) + if name == "" { + return nil, errors.New("invalid name") + } + enabled := true + if in.Enabled != nil { + enabled = *in.Enabled + } + var maxOrder int + _ = s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(sort_order), -1) FROM servers`).Scan(&maxOrder) + id := uuid.NewString() + agentKey := uuid.NewString() + now := time.Now().UTC() + _, err := s.db.ExecContext(ctx, ` +INSERT INTO servers (id, name, url, enabled, sort_order, agent_key, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?)`, + id, name, urlStr, boolToInt(enabled), maxOrder+1, agentKey, now.Format(time.RFC3339Nano)) + if err != nil { + return nil, err + } + return &model.MonitoredServer{ + ID: id, Name: name, URL: urlStr, Enabled: enabled, SortOrder: maxOrder + 1, AgentKey: agentKey, CreatedAt: now, + }, nil +} + +func (s *Store) Update(ctx context.Context, id string, in model.UpdateServerInput) (*model.MonitoredServer, error) { + cur, err := s.Get(ctx, id) + if err != nil { + return nil, err + } + name := cur.Name + urlStr := cur.URL + enabled := cur.Enabled + if in.Name != "" { + name = strings.TrimSpace(in.Name) + } + if in.URL != nil { + urlStr = normalizeURL(strings.TrimSpace(*in.URL)) + } + if in.Enabled != nil { + enabled = *in.Enabled + } + if name == "" { + return nil, errors.New("invalid name") + } + agentKey := cur.AgentKey + if in.AgentKey != nil { + agentKey = *in.AgentKey + } + _, err = s.db.ExecContext(ctx, `UPDATE servers SET name = ?, url = ?, enabled = ?, agent_key = ? WHERE id = ?`, + name, urlStr, boolToInt(enabled), nullIfEmpty(agentKey), id) + if err != nil { + return nil, err + } + cur.Name, cur.URL, cur.Enabled, cur.AgentKey = name, urlStr, enabled, agentKey + return cur, nil +} + +func (s *Store) Get(ctx context.Context, id string) (*model.MonitoredServer, error) { + var m model.MonitoredServer + var enabled int + var created, agKey sql.NullString + err := s.db.QueryRowContext(ctx, ` +SELECT id, name, url, enabled, sort_order, agent_key, created_at FROM servers WHERE id = ?`, id). + Scan(&m.ID, &m.Name, &m.URL, &enabled, &m.SortOrder, &agKey, &created) + if errors.Is(err, sql.ErrNoRows) { + return nil, err + } + if err != nil { + return nil, err + } + m.Enabled = enabled != 0 + if agKey.Valid { + m.AgentKey = agKey.String + } + if t, err := time.Parse(time.RFC3339Nano, created.String); err == nil { + m.CreatedAt = t + } + return &m, nil +} + +// VerifyAgent checks server_id + agent_key for gRPC ingest. +func (s *Store) VerifyAgent(ctx context.Context, id, key string) (*model.MonitoredServer, error) { + srv, err := s.Get(ctx, id) + if err != nil { + return nil, err + } + if !srv.Enabled { + return nil, errors.New("disabled") + } + if srv.AgentKey == "" { + return nil, errors.New("agent_key not set") + } + if subtle.ConstantTimeCompare([]byte(srv.AgentKey), []byte(key)) != 1 { + return nil, errors.New("invalid agent_key") + } + return srv, nil +} + +func nullIfEmpty(s string) any { + if s == "" { + return nil + } + return s +} + +func (s *Store) Delete(ctx context.Context, id string) error { + res, err := s.db.ExecContext(ctx, `DELETE FROM servers WHERE id = ?`, id) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return sql.ErrNoRows + } + return nil +} + +func (s *Store) Reorder(ctx context.Context, ids []string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + for i, id := range ids { + if _, err := tx.ExecContext(ctx, `UPDATE servers SET sort_order = ? WHERE id = ?`, i, id); err != nil { + return err + } + } + return tx.Commit() +} + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} + +func normalizeURL(u string) string { + if u == "" { + return "" + } + if !strings.HasPrefix(strings.ToLower(u), "http://") && !strings.HasPrefix(strings.ToLower(u), "https://") { + return "http://" + u + } + return u +} diff --git a/mengyamonitor-backend-server/main.go b/mengyamonitor-backend-server/main.go new file mode 100644 index 0000000..d403f5e --- /dev/null +++ b/mengyamonitor-backend-server/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "fmt" + "log" + + "mengyamonitor-backend-server/internal/config" + "mengyamonitor-backend-server/internal/grpcrun" + "mengyamonitor-backend-server/internal/hub" + "mengyamonitor-backend-server/internal/ingest" + "mengyamonitor-backend-server/internal/router" + "mengyamonitor-backend-server/internal/store" +) + +func main() { + cfg := config.Load() + st, err := store.Open(cfg.DBPath) + if err != nil { + log.Fatalf("store: %v", err) + } + defer st.Close() + + metricsHub := hub.New() + ingestSrv := &ingest.Server{Store: st, Hub: metricsHub} + + grpcAddr := fmt.Sprintf("%s:%s", cfg.Host, cfg.GRPCPort) + go func() { + log.Printf("gRPC AgentIngest listening on %s", grpcAddr) + if err := grpcrun.Serve(grpcAddr, ingestSrv); err != nil { + log.Fatalf("grpc: %v", err) + } + }() + + httpAddr := fmt.Sprintf("%s:%s", cfg.Host, cfg.Port) + log.Printf("central HTTP/WS on http://%s", httpAddr) + if err := router.New(cfg, st, metricsHub).Run(httpAddr); err != nil { + log.Fatalf("http: %v", err) + } +} diff --git a/mengyamonitor-backend-server/proto/mengya/v1/agent.proto b/mengyamonitor-backend-server/proto/mengya/v1/agent.proto new file mode 100644 index 0000000..371f5e6 --- /dev/null +++ b/mengyamonitor-backend-server/proto/mengya/v1/agent.proto @@ -0,0 +1,41 @@ +syntax = "proto3"; + +package mengya.v1; + +option go_package = "mengyamonitor-backend-server/internal/gen/mengyav1;mengyav1"; + +// AgentIngest: 采集端出站连接,双向流上报指标。 +service AgentIngest { + rpc Connect(stream AgentMessage) returns (stream ControlMessage); +} + +message AgentMessage { + oneof payload { + Hello hello = 1; + Heartbeat heartbeat = 2; + MetricsReport metrics = 3; + } +} + +message Hello { + string server_id = 1; + string agent_key = 2; + string hostname = 3; +} + +message Heartbeat {} + +message MetricsReport { + string payload_json = 1; + int64 collected_at_unix_ms = 2; +} + +message ControlMessage { + oneof payload { + Ack ack = 1; + } +} + +message Ack { + string message = 1; +} diff --git a/mengyamonitor-backend/build_amd64.sh b/mengyamonitor-backend/build_amd64.sh deleted file mode 100644 index 1e6e86e..0000000 --- a/mengyamonitor-backend/build_amd64.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/bin/bash -# 编译脚本 - 兼容旧版本 GLIBC - -echo "开始编译 mengyamonitor-backend..." - -# 禁用 CGO,使用纯 Go 编译(不依赖系统 C 库) -export CGO_ENABLED=0 - -# 设置目标平台 -export GOOS=linux -export GOARCH=amd64 - -# 编译(静态链接) -go build -ldflags="-s -w" -o mengyamonitor-backend . - -if [ $? -eq 0 ]; then - echo "编译成功!" - echo "二进制文件: mengyamonitor-backend" - echo "" - echo "检查文件信息:" - file mengyamonitor-backend - echo "" - echo "检查依赖库:" - ldd mengyamonitor-backend 2>/dev/null || echo "静态链接,无外部依赖" -else - echo "编译失败!" - exit 1 -fi - diff --git a/mengyamonitor-backend/build_arm64.sh b/mengyamonitor-backend/build_arm64.sh deleted file mode 100644 index a51970a..0000000 --- a/mengyamonitor-backend/build_arm64.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/bin/bash -# Linux ARM64 交叉编译脚本 - -echo "开始交叉编译 mengyamonitor-backend (Linux ARM64)..." - -# 禁用 CGO,使用纯 Go 编译(不依赖系统 C 库) -export CGO_ENABLED=0 - -# 设置目标平台为 Linux ARM64 -export GOOS=linux -export GOARCH=arm64 - -# 编译(静态链接) -go build -ldflags="-s -w" -o mengyamonitor-backend-arm64 . - -if [ $? -eq 0 ]; then - echo "编译成功!" - echo "二进制文件: mengyamonitor-backend-arm64" - echo "" - echo "目标平台: Linux ARM64" - echo "编译模式: 静态链接,无外部依赖" - echo "" - echo "检查文件信息:" - file mengyamonitor-backend-arm64 2>/dev/null || echo "文件已生成: mengyamonitor-backend-arm64" - echo "" - ls -lh mengyamonitor-backend-arm64 -else - echo "编译失败!" - exit 1 -fi diff --git a/mengyamonitor-backend/go.mod b/mengyamonitor-backend/go.mod deleted file mode 100644 index ddf7f21..0000000 --- a/mengyamonitor-backend/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module mengyamonitor-backend - -go 1.22 diff --git a/mengyamonitor-backend/latency.go b/mengyamonitor-backend/latency.go deleted file mode 100644 index 564fc1c..0000000 --- a/mengyamonitor-backend/latency.go +++ /dev/null @@ -1,56 +0,0 @@ -package main - -import ( - "fmt" - "net" - "time" -) - -// LatencyInfo 延迟信息 -type LatencyInfo struct { - ClientToServer float64 `json:"clientToServer"` // 客户端到服务器延迟(ms),由前端计算 - External map[string]string `json:"external"` // 外部网站延迟 -} - -// checkExternalLatency 检测外部网站延迟 -func checkExternalLatency(host string, timeout time.Duration) string { - start := time.Now() - - // 尝试 TCP 连接 80 端口 - conn, err := net.DialTimeout("tcp", host+":80", timeout) - if err != nil { - // 如果 80 端口失败,尝试 443 (HTTPS) - conn, err = net.DialTimeout("tcp", host+":443", timeout) - if err != nil { - return "超时" - } - } - defer conn.Close() - - latency := time.Since(start).Milliseconds() - - // 检查是否超时(超过超时时间的一半就认为可能有问题) - if latency > timeout.Milliseconds()/2 { - return "超时" - } - - return fmt.Sprintf("%d ms", latency) -} - -// readExternalLatencies 读取外部网站延迟 -func readExternalLatencies() map[string]string { - latencies := make(map[string]string) - timeout := 3 * time.Second - - // 检测百度 - latencies["baidu.com"] = checkExternalLatency("baidu.com", timeout) - - // 检测谷歌 - latencies["google.com"] = checkExternalLatency("google.com", timeout) - - // 检测 GitHub - latencies["github.com"] = checkExternalLatency("github.com", timeout) - - return latencies -} - diff --git a/mengyamonitor-backend/main.go b/mengyamonitor-backend/main.go deleted file mode 100644 index 39c2d6d..0000000 --- a/mengyamonitor-backend/main.go +++ /dev/null @@ -1,265 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "log" - "net/http" - "os" - "runtime" - "time" -) - -// envelope keeps JSON responses consistent. -type envelope map[string]any - -func main() { - host := getenv("HOST", "0.0.0.0") - port := getenv("PORT", "9292") - - mux := http.NewServeMux() - mux.HandleFunc("/", rootHandler) - mux.HandleFunc("/api/health", healthHandler) - // 拆分的细粒度API端点 - mux.HandleFunc("/api/metrics/cpu", cpuMetricsHandler) - mux.HandleFunc("/api/metrics/memory", memoryMetricsHandler) - mux.HandleFunc("/api/metrics/storage", storageMetricsHandler) - mux.HandleFunc("/api/metrics/gpu", gpuMetricsHandler) - mux.HandleFunc("/api/metrics/network", networkMetricsHandler) - mux.HandleFunc("/api/metrics/system", systemMetricsHandler) - mux.HandleFunc("/api/metrics/docker", dockerMetricsHandler) - mux.HandleFunc("/api/metrics/latency", latencyMetricsHandler) - - srv := &http.Server{ - Addr: fmt.Sprintf("%s:%s", host, port), - Handler: loggingMiddleware(corsMiddleware(mux)), - ReadHeaderTimeout: 5 * time.Second, - } - - log.Printf("server starting on http://%s:%s", host, port) - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("server error: %v", err) - } -} - -func rootHandler(w http.ResponseWriter, r *http.Request) { - respondJSON(w, http.StatusOK, envelope{ - "service": "萌芽监控面板 API", - "version": "1.0.0", - "endpoints": []map[string]string{ - { - "path": "/", - "description": "API 信息和可用端点列表", - }, - { - "path": "/api/health", - "description": "健康检查", - }, - { - "path": "/api/metrics/cpu", - "description": "获取 CPU 监控数据", - }, - { - "path": "/api/metrics/memory", - "description": "获取内存监控数据", - }, - { - "path": "/api/metrics/storage", - "description": "获取存储监控数据", - }, - { - "path": "/api/metrics/gpu", - "description": "获取 GPU 监控数据", - }, - { - "path": "/api/metrics/network", - "description": "获取网络接口监控数据", - }, - { - "path": "/api/metrics/system", - "description": "获取系统统计信息(进程、包、磁盘速度等)", - }, - { - "path": "/api/metrics/docker", - "description": "获取 Docker 容器监控数据", - }, - }, - }) -} - -func healthHandler(w http.ResponseWriter, r *http.Request) { - respondJSON(w, http.StatusOK, envelope{ - "status": "ok", - "timestamp": time.Now().UTC(), - }) -} - -// CPU监控数据 -func cpuMetricsHandler(w http.ResponseWriter, r *http.Request) { - cpuModel := firstMatchInFile("/proc/cpuinfo", "model name") - cpuUsage, err := readCPUUsage() - if err != nil { - cpuUsage = 0 - } - cpuTemp := readCPUTemperature() - perCoreUsage := readPerCoreUsage() - loads := readLoadAverages() - - cores := runtime.NumCPU() - respondJSON(w, http.StatusOK, envelope{ - "data": CPUMetrics{ - Model: cpuModel, - Cores: cores, - UsagePercent: round(cpuUsage, 2), - LoadAverages: loads, - Temperature: cpuTemp, - PerCoreUsage: perCoreUsage, - }, - }) -} - -// 内存监控数据 -func memoryMetricsHandler(w http.ResponseWriter, r *http.Request) { - mem, err := readMemory() - if err != nil { - respondJSON(w, http.StatusInternalServerError, envelope{ - "error": "failed to read memory", - }) - return - } - respondJSON(w, http.StatusOK, envelope{ - "data": mem, - }) -} - -// 存储监控数据 -func storageMetricsHandler(w http.ResponseWriter, r *http.Request) { - storage, err := readAllStorage() - if err != nil { - respondJSON(w, http.StatusInternalServerError, envelope{ - "error": "failed to read storage", - }) - return - } - respondJSON(w, http.StatusOK, envelope{ - "data": storage, - }) -} - -// GPU监控数据 -func gpuMetricsHandler(w http.ResponseWriter, r *http.Request) { - gpu := readGPU() - respondJSON(w, http.StatusOK, envelope{ - "data": gpu, - }) -} - -// 网络监控数据 -func networkMetricsHandler(w http.ResponseWriter, r *http.Request) { - network := readNetworkInterfaces() - respondJSON(w, http.StatusOK, envelope{ - "data": network, - }) -} - -// 系统统计信息 -func systemMetricsHandler(w http.ResponseWriter, r *http.Request) { - stats := readSystemStats() - - // 读取系统基本信息 - hostname, _ := os.Hostname() - osInfo := readOSInfo() - uptime := readUptime() - - // 计算总网络速度(汇总所有接口) - networkInterfaces := readNetworkInterfaces() - var totalRxSpeed, totalTxSpeed float64 - for _, iface := range networkInterfaces { - totalRxSpeed += iface.RxSpeed // bytes/s - totalTxSpeed += iface.TxSpeed // bytes/s - } - // 转换为 MB/s - stats.NetworkRxSpeed = round(totalRxSpeed/1024/1024, 2) - stats.NetworkTxSpeed = round(totalTxSpeed/1024/1024, 2) - - respondJSON(w, http.StatusOK, envelope{ - "data": map[string]interface{}{ - "hostname": hostname, - "os": osInfo, - "uptimeSeconds": uptime, - "processCount": stats.ProcessCount, - "packageCount": stats.PackageCount, - "packageManager": stats.PackageManager, - "temperature": stats.Temperature, - "diskReadSpeed": stats.DiskReadSpeed, - "diskWriteSpeed": stats.DiskWriteSpeed, - "networkRxSpeed": stats.NetworkRxSpeed, - "networkTxSpeed": stats.NetworkTxSpeed, - "topProcesses": stats.TopProcesses, - "systemLogs": stats.SystemLogs, - }, - }) -} - -// Docker监控数据 -func dockerMetricsHandler(w http.ResponseWriter, r *http.Request) { - docker := readDockerStats() - respondJSON(w, http.StatusOK, envelope{ - "data": docker, - }) -} - -// 延迟监控数据 -func latencyMetricsHandler(w http.ResponseWriter, r *http.Request) { - // 记录请求开始时间,用于计算客户端到服务器的延迟 - startTime := time.Now() - - // 读取外部网站延迟 - externalLatencies := readExternalLatencies() - - // 计算服务器处理时间(这个可以作为参考,实际客户端延迟由前端计算) - serverProcessTime := time.Since(startTime).Milliseconds() - - respondJSON(w, http.StatusOK, envelope{ - "data": map[string]interface{}{ - "serverProcessTime": serverProcessTime, // 服务器处理时间(参考) - "external": externalLatencies, - }, - }) -} - -func respondJSON(w http.ResponseWriter, status int, body envelope) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - if err := json.NewEncoder(w).Encode(body); err != nil { - log.Printf("write json error: %v", err) - } -} - -func getenv(key, fallback string) string { - if v, ok := os.LookupEnv(key); ok && v != "" { - return v - } - return fallback -} - -func loggingMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - start := time.Now() - next.ServeHTTP(w, r) - log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start)) - }) -} - -func corsMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type") - if r.Method == http.MethodOptions { - w.WriteHeader(http.StatusNoContent) - return - } - next.ServeHTTP(w, r) - }) -} diff --git a/mengyamonitor-backend/memory.go b/mengyamonitor-backend/memory.go deleted file mode 100644 index 4299a08..0000000 --- a/mengyamonitor-backend/memory.go +++ /dev/null @@ -1,64 +0,0 @@ -package main - -import ( - "bufio" - "os" - "strconv" - "strings" -) - -// readMemory 读取内存信息 -func readMemory() (MemoryMetrics, error) { - totals := map[string]uint64{} - f, err := os.Open("/proc/meminfo") - if err != nil { - return MemoryMetrics{}, err - } - defer f.Close() - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := scanner.Text() - parts := strings.Split(line, ":") - if len(parts) < 2 { - continue - } - key := strings.TrimSpace(parts[0]) - fields := strings.Fields(strings.TrimSpace(parts[1])) - if len(fields) == 0 { - continue - } - value, err := strconv.ParseUint(fields[0], 10, 64) - if err != nil { - continue - } - totals[key] = value * 1024 // kB to bytes - } - total := totals["MemTotal"] - - // 优先使用 MemAvailable(Linux 3.14+),如果没有则计算 - var free uint64 - if available, ok := totals["MemAvailable"]; ok && available > 0 { - free = available - } else { - // 回退到 MemFree + Buffers + Cached(适用于较老的系统) - free = totals["MemFree"] - if buffers, ok := totals["Buffers"]; ok { - free += buffers - } - if cached, ok := totals["Cached"]; ok { - free += cached - } - } - - used := total - free - usedPercent := 0.0 - if total > 0 { - usedPercent = (float64(used) / float64(total)) * 100 - } - return MemoryMetrics{ - TotalBytes: total, - UsedBytes: used, - FreeBytes: free, - UsedPercent: round(usedPercent, 2), - }, nil -} diff --git a/mengyamonitor-frontend/.env.production b/mengyamonitor-frontend/.env.production new file mode 100644 index 0000000..706e29e --- /dev/null +++ b/mengyamonitor-frontend/.env.production @@ -0,0 +1,4 @@ +# 公网:用户通过 https 打开前端时,API 须为浏览器可访问的 https 地址(经 Nginx 反代的中心) +VITE_CENTRAL_API_URL=https://monitor.api.shumengya.top +# +# 若静态站仅内网 http 部署,请改为 http://内网中心IP:9393 后再 npm run build(勿与混合内容冲突) diff --git a/mengyamonitor-frontend/README.md b/mengyamonitor-frontend/README.md index dcedbed..c59ca50 100644 --- a/mengyamonitor-frontend/README.md +++ b/mengyamonitor-frontend/README.md @@ -57,8 +57,8 @@ npm run preview ## 项目结构 ``` src/ -├── api/ # API 调用 -│ └── monitor.ts # 监控 API +├── api/ # API 调用(中心 REST、管理接口) +│ └── central.ts ├── components/ # React 组件 │ ├── ServerCard/ # 服务器卡片组件 │ └── ServerDetail/ # 服务器详情弹窗 @@ -85,11 +85,8 @@ server: { } ``` -### 修改轮询间隔 -编辑 `src/App.tsx` 文件中的 `useServerMonitor` 第二个参数: -```typescript -const statuses = useServerMonitor(servers, 2000); // 2000ms = 2秒 -``` +### 实时指标 +看板通过 `useServerMonitor` 连接中心 WebSocket(`/api/ws/monitor`),无 HTTP 轮询。 ## 部署 diff --git a/mengyamonitor-frontend/dev-dist/registerSW.js b/mengyamonitor-frontend/dev-dist/registerSW.js new file mode 100644 index 0000000..1d5625f --- /dev/null +++ b/mengyamonitor-frontend/dev-dist/registerSW.js @@ -0,0 +1 @@ +if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' }) \ No newline at end of file diff --git a/mengyamonitor-frontend/dev-dist/sw.js b/mengyamonitor-frontend/dev-dist/sw.js new file mode 100644 index 0000000..477c850 --- /dev/null +++ b/mengyamonitor-frontend/dev-dist/sw.js @@ -0,0 +1,103 @@ +/** + * Copyright 2018 Google Inc. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// If the loader is already loaded, just stop. +if (!self.define) { + let registry = {}; + + // Used for `eval` and `importScripts` where we can't get script URL by other means. + // In both cases, it's safe to use a global var because those functions are synchronous. + let nextDefineUri; + + const singleRequire = (uri, parentUri) => { + uri = new URL(uri + ".js", parentUri).href; + return registry[uri] || ( + + new Promise(resolve => { + if ("document" in self) { + const script = document.createElement("script"); + script.src = uri; + script.onload = resolve; + document.head.appendChild(script); + } else { + nextDefineUri = uri; + importScripts(uri); + resolve(); + } + }) + + .then(() => { + let promise = registry[uri]; + if (!promise) { + throw new Error(`Module ${uri} didn’t register its module`); + } + return promise; + }) + ); + }; + + self.define = (depsNames, factory) => { + const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href; + if (registry[uri]) { + // Module is already loading or loaded. + return; + } + let exports = {}; + const require = depUri => singleRequire(depUri, uri); + const specialDeps = { + module: { uri }, + exports, + require + }; + registry[uri] = Promise.all(depsNames.map( + depName => specialDeps[depName] || require(depName) + )).then(deps => { + factory(...deps); + return exports; + }); + }; +} +define(['./workbox-6fc00345'], (function (workbox) { 'use strict'; + + self.skipWaiting(); + workbox.clientsClaim(); + + /** + * The precacheAndRoute() method efficiently caches and responds to + * requests for URLs in the manifest. + * See https://goo.gl/S9QRab + */ + workbox.precacheAndRoute([{ + "url": "registerSW.js", + "revision": "3ca0b8505b4bec776b69afdba2768812" + }, { + "url": "index.html", + "revision": "0.ds91o44ltm" + }], {}); + workbox.cleanupOutdatedCaches(); + workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { + allowlist: [/^\/$/], + denylist: [/^\/central-api(?:\/|$)/] + })); + workbox.registerRoute(/^https:\/\/.*\/api\//i, new workbox.NetworkFirst({ + "cacheName": "api-cache", + "networkTimeoutSeconds": 10, + plugins: [new workbox.ExpirationPlugin({ + maxEntries: 32, + maxAgeSeconds: 300 + }), new workbox.CacheableResponsePlugin({ + statuses: [0, 200] + })] + }), 'GET'); + +})); diff --git a/mengyamonitor-frontend/dev-dist/workbox-6fc00345.js b/mengyamonitor-frontend/dev-dist/workbox-6fc00345.js new file mode 100644 index 0000000..5f2b581 --- /dev/null +++ b/mengyamonitor-frontend/dev-dist/workbox-6fc00345.js @@ -0,0 +1,4705 @@ +define(['exports'], (function (exports) { 'use strict'; + + // @ts-ignore + try { + self['workbox:core:7.3.0'] && _(); + } catch (e) {} + + /* + Copyright 2019 Google LLC + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + const logger = (() => { + // Don't overwrite this value if it's already set. + // See https://github.com/GoogleChrome/workbox/pull/2284#issuecomment-560470923 + if (!('__WB_DISABLE_DEV_LOGS' in globalThis)) { + self.__WB_DISABLE_DEV_LOGS = false; + } + let inGroup = false; + const methodToColorMap = { + debug: `#7f8c8d`, + log: `#2ecc71`, + warn: `#f39c12`, + error: `#c0392b`, + groupCollapsed: `#3498db`, + groupEnd: null // No colored prefix on groupEnd + }; + const print = function (method, args) { + if (self.__WB_DISABLE_DEV_LOGS) { + return; + } + if (method === 'groupCollapsed') { + // Safari doesn't print all console.groupCollapsed() arguments: + // https://bugs.webkit.org/show_bug.cgi?id=182754 + if (/^((?!chrome|android).)*safari/i.test(navigator.userAgent)) { + console[method](...args); + return; + } + } + const styles = [`background: ${methodToColorMap[method]}`, `border-radius: 0.5em`, `color: white`, `font-weight: bold`, `padding: 2px 0.5em`]; + // When in a group, the workbox prefix is not displayed. + const logPrefix = inGroup ? [] : ['%cworkbox', styles.join(';')]; + console[method](...logPrefix, ...args); + if (method === 'groupCollapsed') { + inGroup = true; + } + if (method === 'groupEnd') { + inGroup = false; + } + }; + // eslint-disable-next-line @typescript-eslint/ban-types + const api = {}; + const loggerMethods = Object.keys(methodToColorMap); + for (const key of loggerMethods) { + const method = key; + api[method] = (...args) => { + print(method, args); + }; + } + return api; + })(); + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + const messages$1 = { + 'invalid-value': ({ + paramName, + validValueDescription, + value + }) => { + if (!paramName || !validValueDescription) { + throw new Error(`Unexpected input to 'invalid-value' error.`); + } + return `The '${paramName}' parameter was given a value with an ` + `unexpected value. ${validValueDescription} Received a value of ` + `${JSON.stringify(value)}.`; + }, + 'not-an-array': ({ + moduleName, + className, + funcName, + paramName + }) => { + if (!moduleName || !className || !funcName || !paramName) { + throw new Error(`Unexpected input to 'not-an-array' error.`); + } + return `The parameter '${paramName}' passed into ` + `'${moduleName}.${className}.${funcName}()' must be an array.`; + }, + 'incorrect-type': ({ + expectedType, + paramName, + moduleName, + className, + funcName + }) => { + if (!expectedType || !paramName || !moduleName || !funcName) { + throw new Error(`Unexpected input to 'incorrect-type' error.`); + } + const classNameStr = className ? `${className}.` : ''; + return `The parameter '${paramName}' passed into ` + `'${moduleName}.${classNameStr}` + `${funcName}()' must be of type ${expectedType}.`; + }, + 'incorrect-class': ({ + expectedClassName, + paramName, + moduleName, + className, + funcName, + isReturnValueProblem + }) => { + if (!expectedClassName || !moduleName || !funcName) { + throw new Error(`Unexpected input to 'incorrect-class' error.`); + } + const classNameStr = className ? `${className}.` : ''; + if (isReturnValueProblem) { + return `The return value from ` + `'${moduleName}.${classNameStr}${funcName}()' ` + `must be an instance of class ${expectedClassName}.`; + } + return `The parameter '${paramName}' passed into ` + `'${moduleName}.${classNameStr}${funcName}()' ` + `must be an instance of class ${expectedClassName}.`; + }, + 'missing-a-method': ({ + expectedMethod, + paramName, + moduleName, + className, + funcName + }) => { + if (!expectedMethod || !paramName || !moduleName || !className || !funcName) { + throw new Error(`Unexpected input to 'missing-a-method' error.`); + } + return `${moduleName}.${className}.${funcName}() expected the ` + `'${paramName}' parameter to expose a '${expectedMethod}' method.`; + }, + 'add-to-cache-list-unexpected-type': ({ + entry + }) => { + return `An unexpected entry was passed to ` + `'workbox-precaching.PrecacheController.addToCacheList()' The entry ` + `'${JSON.stringify(entry)}' isn't supported. You must supply an array of ` + `strings with one or more characters, objects with a url property or ` + `Request objects.`; + }, + 'add-to-cache-list-conflicting-entries': ({ + firstEntry, + secondEntry + }) => { + if (!firstEntry || !secondEntry) { + throw new Error(`Unexpected input to ` + `'add-to-cache-list-duplicate-entries' error.`); + } + return `Two of the entries passed to ` + `'workbox-precaching.PrecacheController.addToCacheList()' had the URL ` + `${firstEntry} but different revision details. Workbox is ` + `unable to cache and version the asset correctly. Please remove one ` + `of the entries.`; + }, + 'plugin-error-request-will-fetch': ({ + thrownErrorMessage + }) => { + if (!thrownErrorMessage) { + throw new Error(`Unexpected input to ` + `'plugin-error-request-will-fetch', error.`); + } + return `An error was thrown by a plugins 'requestWillFetch()' method. ` + `The thrown error message was: '${thrownErrorMessage}'.`; + }, + 'invalid-cache-name': ({ + cacheNameId, + value + }) => { + if (!cacheNameId) { + throw new Error(`Expected a 'cacheNameId' for error 'invalid-cache-name'`); + } + return `You must provide a name containing at least one character for ` + `setCacheDetails({${cacheNameId}: '...'}). Received a value of ` + `'${JSON.stringify(value)}'`; + }, + 'unregister-route-but-not-found-with-method': ({ + method + }) => { + if (!method) { + throw new Error(`Unexpected input to ` + `'unregister-route-but-not-found-with-method' error.`); + } + return `The route you're trying to unregister was not previously ` + `registered for the method type '${method}'.`; + }, + 'unregister-route-route-not-registered': () => { + return `The route you're trying to unregister was not previously ` + `registered.`; + }, + 'queue-replay-failed': ({ + name + }) => { + return `Replaying the background sync queue '${name}' failed.`; + }, + 'duplicate-queue-name': ({ + name + }) => { + return `The Queue name '${name}' is already being used. ` + `All instances of backgroundSync.Queue must be given unique names.`; + }, + 'expired-test-without-max-age': ({ + methodName, + paramName + }) => { + return `The '${methodName}()' method can only be used when the ` + `'${paramName}' is used in the constructor.`; + }, + 'unsupported-route-type': ({ + moduleName, + className, + funcName, + paramName + }) => { + return `The supplied '${paramName}' parameter was an unsupported type. ` + `Please check the docs for ${moduleName}.${className}.${funcName} for ` + `valid input types.`; + }, + 'not-array-of-class': ({ + value, + expectedClass, + moduleName, + className, + funcName, + paramName + }) => { + return `The supplied '${paramName}' parameter must be an array of ` + `'${expectedClass}' objects. Received '${JSON.stringify(value)},'. ` + `Please check the call to ${moduleName}.${className}.${funcName}() ` + `to fix the issue.`; + }, + 'max-entries-or-age-required': ({ + moduleName, + className, + funcName + }) => { + return `You must define either config.maxEntries or config.maxAgeSeconds` + `in ${moduleName}.${className}.${funcName}`; + }, + 'statuses-or-headers-required': ({ + moduleName, + className, + funcName + }) => { + return `You must define either config.statuses or config.headers` + `in ${moduleName}.${className}.${funcName}`; + }, + 'invalid-string': ({ + moduleName, + funcName, + paramName + }) => { + if (!paramName || !moduleName || !funcName) { + throw new Error(`Unexpected input to 'invalid-string' error.`); + } + return `When using strings, the '${paramName}' parameter must start with ` + `'http' (for cross-origin matches) or '/' (for same-origin matches). ` + `Please see the docs for ${moduleName}.${funcName}() for ` + `more info.`; + }, + 'channel-name-required': () => { + return `You must provide a channelName to construct a ` + `BroadcastCacheUpdate instance.`; + }, + 'invalid-responses-are-same-args': () => { + return `The arguments passed into responsesAreSame() appear to be ` + `invalid. Please ensure valid Responses are used.`; + }, + 'expire-custom-caches-only': () => { + return `You must provide a 'cacheName' property when using the ` + `expiration plugin with a runtime caching strategy.`; + }, + 'unit-must-be-bytes': ({ + normalizedRangeHeader + }) => { + if (!normalizedRangeHeader) { + throw new Error(`Unexpected input to 'unit-must-be-bytes' error.`); + } + return `The 'unit' portion of the Range header must be set to 'bytes'. ` + `The Range header provided was "${normalizedRangeHeader}"`; + }, + 'single-range-only': ({ + normalizedRangeHeader + }) => { + if (!normalizedRangeHeader) { + throw new Error(`Unexpected input to 'single-range-only' error.`); + } + return `Multiple ranges are not supported. Please use a single start ` + `value, and optional end value. The Range header provided was ` + `"${normalizedRangeHeader}"`; + }, + 'invalid-range-values': ({ + normalizedRangeHeader + }) => { + if (!normalizedRangeHeader) { + throw new Error(`Unexpected input to 'invalid-range-values' error.`); + } + return `The Range header is missing both start and end values. At least ` + `one of those values is needed. The Range header provided was ` + `"${normalizedRangeHeader}"`; + }, + 'no-range-header': () => { + return `No Range header was found in the Request provided.`; + }, + 'range-not-satisfiable': ({ + size, + start, + end + }) => { + return `The start (${start}) and end (${end}) values in the Range are ` + `not satisfiable by the cached response, which is ${size} bytes.`; + }, + 'attempt-to-cache-non-get-request': ({ + url, + method + }) => { + return `Unable to cache '${url}' because it is a '${method}' request and ` + `only 'GET' requests can be cached.`; + }, + 'cache-put-with-no-response': ({ + url + }) => { + return `There was an attempt to cache '${url}' but the response was not ` + `defined.`; + }, + 'no-response': ({ + url, + error + }) => { + let message = `The strategy could not generate a response for '${url}'.`; + if (error) { + message += ` The underlying error is ${error}.`; + } + return message; + }, + 'bad-precaching-response': ({ + url, + status + }) => { + return `The precaching request for '${url}' failed` + (status ? ` with an HTTP status of ${status}.` : `.`); + }, + 'non-precached-url': ({ + url + }) => { + return `createHandlerBoundToURL('${url}') was called, but that URL is ` + `not precached. Please pass in a URL that is precached instead.`; + }, + 'add-to-cache-list-conflicting-integrities': ({ + url + }) => { + return `Two of the entries passed to ` + `'workbox-precaching.PrecacheController.addToCacheList()' had the URL ` + `${url} with different integrity values. Please remove one of them.`; + }, + 'missing-precache-entry': ({ + cacheName, + url + }) => { + return `Unable to find a precached response in ${cacheName} for ${url}.`; + }, + 'cross-origin-copy-response': ({ + origin + }) => { + return `workbox-core.copyResponse() can only be used with same-origin ` + `responses. It was passed a response with origin ${origin}.`; + }, + 'opaque-streams-source': ({ + type + }) => { + const message = `One of the workbox-streams sources resulted in an ` + `'${type}' response.`; + if (type === 'opaqueredirect') { + return `${message} Please do not use a navigation request that results ` + `in a redirect as a source.`; + } + return `${message} Please ensure your sources are CORS-enabled.`; + } + }; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + const generatorFunction = (code, details = {}) => { + const message = messages$1[code]; + if (!message) { + throw new Error(`Unable to find message for code '${code}'.`); + } + return message(details); + }; + const messageGenerator = generatorFunction; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Workbox errors should be thrown with this class. + * This allows use to ensure the type easily in tests, + * helps developers identify errors from workbox + * easily and allows use to optimise error + * messages correctly. + * + * @private + */ + class WorkboxError extends Error { + /** + * + * @param {string} errorCode The error code that + * identifies this particular error. + * @param {Object=} details Any relevant arguments + * that will help developers identify issues should + * be added as a key on the context object. + */ + constructor(errorCode, details) { + const message = messageGenerator(errorCode, details); + super(message); + this.name = errorCode; + this.details = details; + } + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /* + * This method throws if the supplied value is not an array. + * The destructed values are required to produce a meaningful error for users. + * The destructed and restructured object is so it's clear what is + * needed. + */ + const isArray = (value, details) => { + if (!Array.isArray(value)) { + throw new WorkboxError('not-an-array', details); + } + }; + const hasMethod = (object, expectedMethod, details) => { + const type = typeof object[expectedMethod]; + if (type !== 'function') { + details['expectedMethod'] = expectedMethod; + throw new WorkboxError('missing-a-method', details); + } + }; + const isType = (object, expectedType, details) => { + if (typeof object !== expectedType) { + details['expectedType'] = expectedType; + throw new WorkboxError('incorrect-type', details); + } + }; + const isInstance = (object, + // Need the general type to do the check later. + // eslint-disable-next-line @typescript-eslint/ban-types + expectedClass, details) => { + if (!(object instanceof expectedClass)) { + details['expectedClassName'] = expectedClass.name; + throw new WorkboxError('incorrect-class', details); + } + }; + const isOneOf = (value, validValues, details) => { + if (!validValues.includes(value)) { + details['validValueDescription'] = `Valid values are ${JSON.stringify(validValues)}.`; + throw new WorkboxError('invalid-value', details); + } + }; + const isArrayOfClass = (value, + // Need general type to do check later. + expectedClass, + // eslint-disable-line + details) => { + const error = new WorkboxError('not-array-of-class', details); + if (!Array.isArray(value)) { + throw error; + } + for (const item of value) { + if (!(item instanceof expectedClass)) { + throw error; + } + } + }; + const finalAssertExports = { + hasMethod, + isArray, + isInstance, + isOneOf, + isType, + isArrayOfClass + }; + + // @ts-ignore + try { + self['workbox:routing:7.3.0'] && _(); + } catch (e) {} + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * The default HTTP method, 'GET', used when there's no specific method + * configured for a route. + * + * @type {string} + * + * @private + */ + const defaultMethod = 'GET'; + /** + * The list of valid HTTP methods associated with requests that could be routed. + * + * @type {Array} + * + * @private + */ + const validMethods = ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT']; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * @param {function()|Object} handler Either a function, or an object with a + * 'handle' method. + * @return {Object} An object with a handle method. + * + * @private + */ + const normalizeHandler = handler => { + if (handler && typeof handler === 'object') { + { + finalAssertExports.hasMethod(handler, 'handle', { + moduleName: 'workbox-routing', + className: 'Route', + funcName: 'constructor', + paramName: 'handler' + }); + } + return handler; + } else { + { + finalAssertExports.isType(handler, 'function', { + moduleName: 'workbox-routing', + className: 'Route', + funcName: 'constructor', + paramName: 'handler' + }); + } + return { + handle: handler + }; + } + }; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * A `Route` consists of a pair of callback functions, "match" and "handler". + * The "match" callback determine if a route should be used to "handle" a + * request by returning a non-falsy value if it can. The "handler" callback + * is called when there is a match and should return a Promise that resolves + * to a `Response`. + * + * @memberof workbox-routing + */ + class Route { + /** + * Constructor for Route class. + * + * @param {workbox-routing~matchCallback} match + * A callback function that determines whether the route matches a given + * `fetch` event by returning a non-falsy value. + * @param {workbox-routing~handlerCallback} handler A callback + * function that returns a Promise resolving to a Response. + * @param {string} [method='GET'] The HTTP method to match the Route + * against. + */ + constructor(match, handler, method = defaultMethod) { + { + finalAssertExports.isType(match, 'function', { + moduleName: 'workbox-routing', + className: 'Route', + funcName: 'constructor', + paramName: 'match' + }); + if (method) { + finalAssertExports.isOneOf(method, validMethods, { + paramName: 'method' + }); + } + } + // These values are referenced directly by Router so cannot be + // altered by minificaton. + this.handler = normalizeHandler(handler); + this.match = match; + this.method = method; + } + /** + * + * @param {workbox-routing-handlerCallback} handler A callback + * function that returns a Promise resolving to a Response + */ + setCatchHandler(handler) { + this.catchHandler = normalizeHandler(handler); + } + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * RegExpRoute makes it easy to create a regular expression based + * {@link workbox-routing.Route}. + * + * For same-origin requests the RegExp only needs to match part of the URL. For + * requests against third-party servers, you must define a RegExp that matches + * the start of the URL. + * + * @memberof workbox-routing + * @extends workbox-routing.Route + */ + class RegExpRoute extends Route { + /** + * If the regular expression contains + * [capture groups]{@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp#grouping-back-references}, + * the captured values will be passed to the + * {@link workbox-routing~handlerCallback} `params` + * argument. + * + * @param {RegExp} regExp The regular expression to match against URLs. + * @param {workbox-routing~handlerCallback} handler A callback + * function that returns a Promise resulting in a Response. + * @param {string} [method='GET'] The HTTP method to match the Route + * against. + */ + constructor(regExp, handler, method) { + { + finalAssertExports.isInstance(regExp, RegExp, { + moduleName: 'workbox-routing', + className: 'RegExpRoute', + funcName: 'constructor', + paramName: 'pattern' + }); + } + const match = ({ + url + }) => { + const result = regExp.exec(url.href); + // Return immediately if there's no match. + if (!result) { + return; + } + // Require that the match start at the first character in the URL string + // if it's a cross-origin request. + // See https://github.com/GoogleChrome/workbox/issues/281 for the context + // behind this behavior. + if (url.origin !== location.origin && result.index !== 0) { + { + logger.debug(`The regular expression '${regExp.toString()}' only partially matched ` + `against the cross-origin URL '${url.toString()}'. RegExpRoute's will only ` + `handle cross-origin requests if they match the entire URL.`); + } + return; + } + // If the route matches, but there aren't any capture groups defined, then + // this will return [], which is truthy and therefore sufficient to + // indicate a match. + // If there are capture groups, then it will return their values. + return result.slice(1); + }; + super(match, handler, method); + } + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + const getFriendlyURL = url => { + const urlObj = new URL(String(url), location.href); + // See https://github.com/GoogleChrome/workbox/issues/2323 + // We want to include everything, except for the origin if it's same-origin. + return urlObj.href.replace(new RegExp(`^${location.origin}`), ''); + }; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * The Router can be used to process a `FetchEvent` using one or more + * {@link workbox-routing.Route}, responding with a `Response` if + * a matching route exists. + * + * If no route matches a given a request, the Router will use a "default" + * handler if one is defined. + * + * Should the matching Route throw an error, the Router will use a "catch" + * handler if one is defined to gracefully deal with issues and respond with a + * Request. + * + * If a request matches multiple routes, the **earliest** registered route will + * be used to respond to the request. + * + * @memberof workbox-routing + */ + class Router { + /** + * Initializes a new Router. + */ + constructor() { + this._routes = new Map(); + this._defaultHandlerMap = new Map(); + } + /** + * @return {Map>} routes A `Map` of HTTP + * method name ('GET', etc.) to an array of all the corresponding `Route` + * instances that are registered. + */ + get routes() { + return this._routes; + } + /** + * Adds a fetch event listener to respond to events when a route matches + * the event's request. + */ + addFetchListener() { + // See https://github.com/Microsoft/TypeScript/issues/28357#issuecomment-436484705 + self.addEventListener('fetch', event => { + const { + request + } = event; + const responsePromise = this.handleRequest({ + request, + event + }); + if (responsePromise) { + event.respondWith(responsePromise); + } + }); + } + /** + * Adds a message event listener for URLs to cache from the window. + * This is useful to cache resources loaded on the page prior to when the + * service worker started controlling it. + * + * The format of the message data sent from the window should be as follows. + * Where the `urlsToCache` array may consist of URL strings or an array of + * URL string + `requestInit` object (the same as you'd pass to `fetch()`). + * + * ``` + * { + * type: 'CACHE_URLS', + * payload: { + * urlsToCache: [ + * './script1.js', + * './script2.js', + * ['./script3.js', {mode: 'no-cors'}], + * ], + * }, + * } + * ``` + */ + addCacheListener() { + // See https://github.com/Microsoft/TypeScript/issues/28357#issuecomment-436484705 + self.addEventListener('message', event => { + // event.data is type 'any' + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (event.data && event.data.type === 'CACHE_URLS') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { + payload + } = event.data; + { + logger.debug(`Caching URLs from the window`, payload.urlsToCache); + } + const requestPromises = Promise.all(payload.urlsToCache.map(entry => { + if (typeof entry === 'string') { + entry = [entry]; + } + const request = new Request(...entry); + return this.handleRequest({ + request, + event + }); + // TODO(philipwalton): TypeScript errors without this typecast for + // some reason (probably a bug). The real type here should work but + // doesn't: `Array | undefined>`. + })); // TypeScript + event.waitUntil(requestPromises); + // If a MessageChannel was used, reply to the message on success. + if (event.ports && event.ports[0]) { + void requestPromises.then(() => event.ports[0].postMessage(true)); + } + } + }); + } + /** + * Apply the routing rules to a FetchEvent object to get a Response from an + * appropriate Route's handler. + * + * @param {Object} options + * @param {Request} options.request The request to handle. + * @param {ExtendableEvent} options.event The event that triggered the + * request. + * @return {Promise|undefined} A promise is returned if a + * registered route can handle the request. If there is no matching + * route and there's no `defaultHandler`, `undefined` is returned. + */ + handleRequest({ + request, + event + }) { + { + finalAssertExports.isInstance(request, Request, { + moduleName: 'workbox-routing', + className: 'Router', + funcName: 'handleRequest', + paramName: 'options.request' + }); + } + const url = new URL(request.url, location.href); + if (!url.protocol.startsWith('http')) { + { + logger.debug(`Workbox Router only supports URLs that start with 'http'.`); + } + return; + } + const sameOrigin = url.origin === location.origin; + const { + params, + route + } = this.findMatchingRoute({ + event, + request, + sameOrigin, + url + }); + let handler = route && route.handler; + const debugMessages = []; + { + if (handler) { + debugMessages.push([`Found a route to handle this request:`, route]); + if (params) { + debugMessages.push([`Passing the following params to the route's handler:`, params]); + } + } + } + // If we don't have a handler because there was no matching route, then + // fall back to defaultHandler if that's defined. + const method = request.method; + if (!handler && this._defaultHandlerMap.has(method)) { + { + debugMessages.push(`Failed to find a matching route. Falling ` + `back to the default handler for ${method}.`); + } + handler = this._defaultHandlerMap.get(method); + } + if (!handler) { + { + // No handler so Workbox will do nothing. If logs is set of debug + // i.e. verbose, we should print out this information. + logger.debug(`No route found for: ${getFriendlyURL(url)}`); + } + return; + } + { + // We have a handler, meaning Workbox is going to handle the route. + // print the routing details to the console. + logger.groupCollapsed(`Router is responding to: ${getFriendlyURL(url)}`); + debugMessages.forEach(msg => { + if (Array.isArray(msg)) { + logger.log(...msg); + } else { + logger.log(msg); + } + }); + logger.groupEnd(); + } + // Wrap in try and catch in case the handle method throws a synchronous + // error. It should still callback to the catch handler. + let responsePromise; + try { + responsePromise = handler.handle({ + url, + request, + event, + params + }); + } catch (err) { + responsePromise = Promise.reject(err); + } + // Get route's catch handler, if it exists + const catchHandler = route && route.catchHandler; + if (responsePromise instanceof Promise && (this._catchHandler || catchHandler)) { + responsePromise = responsePromise.catch(async err => { + // If there's a route catch handler, process that first + if (catchHandler) { + { + // Still include URL here as it will be async from the console group + // and may not make sense without the URL + logger.groupCollapsed(`Error thrown when responding to: ` + ` ${getFriendlyURL(url)}. Falling back to route's Catch Handler.`); + logger.error(`Error thrown by:`, route); + logger.error(err); + logger.groupEnd(); + } + try { + return await catchHandler.handle({ + url, + request, + event, + params + }); + } catch (catchErr) { + if (catchErr instanceof Error) { + err = catchErr; + } + } + } + if (this._catchHandler) { + { + // Still include URL here as it will be async from the console group + // and may not make sense without the URL + logger.groupCollapsed(`Error thrown when responding to: ` + ` ${getFriendlyURL(url)}. Falling back to global Catch Handler.`); + logger.error(`Error thrown by:`, route); + logger.error(err); + logger.groupEnd(); + } + return this._catchHandler.handle({ + url, + request, + event + }); + } + throw err; + }); + } + return responsePromise; + } + /** + * Checks a request and URL (and optionally an event) against the list of + * registered routes, and if there's a match, returns the corresponding + * route along with any params generated by the match. + * + * @param {Object} options + * @param {URL} options.url + * @param {boolean} options.sameOrigin The result of comparing `url.origin` + * against the current origin. + * @param {Request} options.request The request to match. + * @param {Event} options.event The corresponding event. + * @return {Object} An object with `route` and `params` properties. + * They are populated if a matching route was found or `undefined` + * otherwise. + */ + findMatchingRoute({ + url, + sameOrigin, + request, + event + }) { + const routes = this._routes.get(request.method) || []; + for (const route of routes) { + let params; + // route.match returns type any, not possible to change right now. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const matchResult = route.match({ + url, + sameOrigin, + request, + event + }); + if (matchResult) { + { + // Warn developers that using an async matchCallback is almost always + // not the right thing to do. + if (matchResult instanceof Promise) { + logger.warn(`While routing ${getFriendlyURL(url)}, an async ` + `matchCallback function was used. Please convert the ` + `following route to use a synchronous matchCallback function:`, route); + } + } + // See https://github.com/GoogleChrome/workbox/issues/2079 + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + params = matchResult; + if (Array.isArray(params) && params.length === 0) { + // Instead of passing an empty array in as params, use undefined. + params = undefined; + } else if (matchResult.constructor === Object && + // eslint-disable-line + Object.keys(matchResult).length === 0) { + // Instead of passing an empty object in as params, use undefined. + params = undefined; + } else if (typeof matchResult === 'boolean') { + // For the boolean value true (rather than just something truth-y), + // don't set params. + // See https://github.com/GoogleChrome/workbox/pull/2134#issuecomment-513924353 + params = undefined; + } + // Return early if have a match. + return { + route, + params + }; + } + } + // If no match was found above, return and empty object. + return {}; + } + /** + * Define a default `handler` that's called when no routes explicitly + * match the incoming request. + * + * Each HTTP method ('GET', 'POST', etc.) gets its own default handler. + * + * Without a default handler, unmatched requests will go against the + * network as if there were no service worker present. + * + * @param {workbox-routing~handlerCallback} handler A callback + * function that returns a Promise resulting in a Response. + * @param {string} [method='GET'] The HTTP method to associate with this + * default handler. Each method has its own default. + */ + setDefaultHandler(handler, method = defaultMethod) { + this._defaultHandlerMap.set(method, normalizeHandler(handler)); + } + /** + * If a Route throws an error while handling a request, this `handler` + * will be called and given a chance to provide a response. + * + * @param {workbox-routing~handlerCallback} handler A callback + * function that returns a Promise resulting in a Response. + */ + setCatchHandler(handler) { + this._catchHandler = normalizeHandler(handler); + } + /** + * Registers a route with the router. + * + * @param {workbox-routing.Route} route The route to register. + */ + registerRoute(route) { + { + finalAssertExports.isType(route, 'object', { + moduleName: 'workbox-routing', + className: 'Router', + funcName: 'registerRoute', + paramName: 'route' + }); + finalAssertExports.hasMethod(route, 'match', { + moduleName: 'workbox-routing', + className: 'Router', + funcName: 'registerRoute', + paramName: 'route' + }); + finalAssertExports.isType(route.handler, 'object', { + moduleName: 'workbox-routing', + className: 'Router', + funcName: 'registerRoute', + paramName: 'route' + }); + finalAssertExports.hasMethod(route.handler, 'handle', { + moduleName: 'workbox-routing', + className: 'Router', + funcName: 'registerRoute', + paramName: 'route.handler' + }); + finalAssertExports.isType(route.method, 'string', { + moduleName: 'workbox-routing', + className: 'Router', + funcName: 'registerRoute', + paramName: 'route.method' + }); + } + if (!this._routes.has(route.method)) { + this._routes.set(route.method, []); + } + // Give precedence to all of the earlier routes by adding this additional + // route to the end of the array. + this._routes.get(route.method).push(route); + } + /** + * Unregisters a route with the router. + * + * @param {workbox-routing.Route} route The route to unregister. + */ + unregisterRoute(route) { + if (!this._routes.has(route.method)) { + throw new WorkboxError('unregister-route-but-not-found-with-method', { + method: route.method + }); + } + const routeIndex = this._routes.get(route.method).indexOf(route); + if (routeIndex > -1) { + this._routes.get(route.method).splice(routeIndex, 1); + } else { + throw new WorkboxError('unregister-route-route-not-registered'); + } + } + } + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + let defaultRouter; + /** + * Creates a new, singleton Router instance if one does not exist. If one + * does already exist, that instance is returned. + * + * @private + * @return {Router} + */ + const getOrCreateDefaultRouter = () => { + if (!defaultRouter) { + defaultRouter = new Router(); + // The helpers that use the default Router assume these listeners exist. + defaultRouter.addFetchListener(); + defaultRouter.addCacheListener(); + } + return defaultRouter; + }; + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Easily register a RegExp, string, or function with a caching + * strategy to a singleton Router instance. + * + * This method will generate a Route for you if needed and + * call {@link workbox-routing.Router#registerRoute}. + * + * @param {RegExp|string|workbox-routing.Route~matchCallback|workbox-routing.Route} capture + * If the capture param is a `Route`, all other arguments will be ignored. + * @param {workbox-routing~handlerCallback} [handler] A callback + * function that returns a Promise resulting in a Response. This parameter + * is required if `capture` is not a `Route` object. + * @param {string} [method='GET'] The HTTP method to match the Route + * against. + * @return {workbox-routing.Route} The generated `Route`. + * + * @memberof workbox-routing + */ + function registerRoute(capture, handler, method) { + let route; + if (typeof capture === 'string') { + const captureUrl = new URL(capture, location.href); + { + if (!(capture.startsWith('/') || capture.startsWith('http'))) { + throw new WorkboxError('invalid-string', { + moduleName: 'workbox-routing', + funcName: 'registerRoute', + paramName: 'capture' + }); + } + // We want to check if Express-style wildcards are in the pathname only. + // TODO: Remove this log message in v4. + const valueToCheck = capture.startsWith('http') ? captureUrl.pathname : capture; + // See https://github.com/pillarjs/path-to-regexp#parameters + const wildcards = '[*:?+]'; + if (new RegExp(`${wildcards}`).exec(valueToCheck)) { + logger.debug(`The '$capture' parameter contains an Express-style wildcard ` + `character (${wildcards}). Strings are now always interpreted as ` + `exact matches; use a RegExp for partial or wildcard matches.`); + } + } + const matchCallback = ({ + url + }) => { + { + if (url.pathname === captureUrl.pathname && url.origin !== captureUrl.origin) { + logger.debug(`${capture} only partially matches the cross-origin URL ` + `${url.toString()}. This route will only handle cross-origin requests ` + `if they match the entire URL.`); + } + } + return url.href === captureUrl.href; + }; + // If `capture` is a string then `handler` and `method` must be present. + route = new Route(matchCallback, handler, method); + } else if (capture instanceof RegExp) { + // If `capture` is a `RegExp` then `handler` and `method` must be present. + route = new RegExpRoute(capture, handler, method); + } else if (typeof capture === 'function') { + // If `capture` is a function then `handler` and `method` must be present. + route = new Route(capture, handler, method); + } else if (capture instanceof Route) { + route = capture; + } else { + throw new WorkboxError('unsupported-route-type', { + moduleName: 'workbox-routing', + funcName: 'registerRoute', + paramName: 'capture' + }); + } + const defaultRouter = getOrCreateDefaultRouter(); + defaultRouter.registerRoute(route); + return route; + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + const _cacheNameDetails = { + googleAnalytics: 'googleAnalytics', + precache: 'precache-v2', + prefix: 'workbox', + runtime: 'runtime', + suffix: typeof registration !== 'undefined' ? registration.scope : '' + }; + const _createCacheName = cacheName => { + return [_cacheNameDetails.prefix, cacheName, _cacheNameDetails.suffix].filter(value => value && value.length > 0).join('-'); + }; + const eachCacheNameDetail = fn => { + for (const key of Object.keys(_cacheNameDetails)) { + fn(key); + } + }; + const cacheNames = { + updateDetails: details => { + eachCacheNameDetail(key => { + if (typeof details[key] === 'string') { + _cacheNameDetails[key] = details[key]; + } + }); + }, + getGoogleAnalyticsName: userCacheName => { + return userCacheName || _createCacheName(_cacheNameDetails.googleAnalytics); + }, + getPrecacheName: userCacheName => { + return userCacheName || _createCacheName(_cacheNameDetails.precache); + }, + getPrefix: () => { + return _cacheNameDetails.prefix; + }, + getRuntimeName: userCacheName => { + return userCacheName || _createCacheName(_cacheNameDetails.runtime); + }, + getSuffix: () => { + return _cacheNameDetails.suffix; + } + }; + + /* + Copyright 2019 Google LLC + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * A helper function that prevents a promise from being flagged as unused. + * + * @private + **/ + function dontWaitFor(promise) { + // Effective no-op. + void promise.then(() => {}); + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + // Callbacks to be executed whenever there's a quota error. + // Can't change Function type right now. + // eslint-disable-next-line @typescript-eslint/ban-types + const quotaErrorCallbacks = new Set(); + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Adds a function to the set of quotaErrorCallbacks that will be executed if + * there's a quota error. + * + * @param {Function} callback + * @memberof workbox-core + */ + // Can't change Function type + // eslint-disable-next-line @typescript-eslint/ban-types + function registerQuotaErrorCallback(callback) { + { + finalAssertExports.isType(callback, 'function', { + moduleName: 'workbox-core', + funcName: 'register', + paramName: 'callback' + }); + } + quotaErrorCallbacks.add(callback); + { + logger.log('Registered a callback to respond to quota errors.', callback); + } + } + + function _extends() { + return _extends = Object.assign ? Object.assign.bind() : function (n) { + for (var e = 1; e < arguments.length; e++) { + var t = arguments[e]; + for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); + } + return n; + }, _extends.apply(null, arguments); + } + + const instanceOfAny = (object, constructors) => constructors.some(c => object instanceof c); + let idbProxyableTypes; + let cursorAdvanceMethods; + // This is a function to prevent it throwing up in node environments. + function getIdbProxyableTypes() { + return idbProxyableTypes || (idbProxyableTypes = [IDBDatabase, IDBObjectStore, IDBIndex, IDBCursor, IDBTransaction]); + } + // This is a function to prevent it throwing up in node environments. + function getCursorAdvanceMethods() { + return cursorAdvanceMethods || (cursorAdvanceMethods = [IDBCursor.prototype.advance, IDBCursor.prototype.continue, IDBCursor.prototype.continuePrimaryKey]); + } + const cursorRequestMap = new WeakMap(); + const transactionDoneMap = new WeakMap(); + const transactionStoreNamesMap = new WeakMap(); + const transformCache = new WeakMap(); + const reverseTransformCache = new WeakMap(); + function promisifyRequest(request) { + const promise = new Promise((resolve, reject) => { + const unlisten = () => { + request.removeEventListener('success', success); + request.removeEventListener('error', error); + }; + const success = () => { + resolve(wrap(request.result)); + unlisten(); + }; + const error = () => { + reject(request.error); + unlisten(); + }; + request.addEventListener('success', success); + request.addEventListener('error', error); + }); + promise.then(value => { + // Since cursoring reuses the IDBRequest (*sigh*), we cache it for later retrieval + // (see wrapFunction). + if (value instanceof IDBCursor) { + cursorRequestMap.set(value, request); + } + // Catching to avoid "Uncaught Promise exceptions" + }).catch(() => {}); + // This mapping exists in reverseTransformCache but doesn't doesn't exist in transformCache. This + // is because we create many promises from a single IDBRequest. + reverseTransformCache.set(promise, request); + return promise; + } + function cacheDonePromiseForTransaction(tx) { + // Early bail if we've already created a done promise for this transaction. + if (transactionDoneMap.has(tx)) return; + const done = new Promise((resolve, reject) => { + const unlisten = () => { + tx.removeEventListener('complete', complete); + tx.removeEventListener('error', error); + tx.removeEventListener('abort', error); + }; + const complete = () => { + resolve(); + unlisten(); + }; + const error = () => { + reject(tx.error || new DOMException('AbortError', 'AbortError')); + unlisten(); + }; + tx.addEventListener('complete', complete); + tx.addEventListener('error', error); + tx.addEventListener('abort', error); + }); + // Cache it for later retrieval. + transactionDoneMap.set(tx, done); + } + let idbProxyTraps = { + get(target, prop, receiver) { + if (target instanceof IDBTransaction) { + // Special handling for transaction.done. + if (prop === 'done') return transactionDoneMap.get(target); + // Polyfill for objectStoreNames because of Edge. + if (prop === 'objectStoreNames') { + return target.objectStoreNames || transactionStoreNamesMap.get(target); + } + // Make tx.store return the only store in the transaction, or undefined if there are many. + if (prop === 'store') { + return receiver.objectStoreNames[1] ? undefined : receiver.objectStore(receiver.objectStoreNames[0]); + } + } + // Else transform whatever we get back. + return wrap(target[prop]); + }, + set(target, prop, value) { + target[prop] = value; + return true; + }, + has(target, prop) { + if (target instanceof IDBTransaction && (prop === 'done' || prop === 'store')) { + return true; + } + return prop in target; + } + }; + function replaceTraps(callback) { + idbProxyTraps = callback(idbProxyTraps); + } + function wrapFunction(func) { + // Due to expected object equality (which is enforced by the caching in `wrap`), we + // only create one new func per func. + // Edge doesn't support objectStoreNames (booo), so we polyfill it here. + if (func === IDBDatabase.prototype.transaction && !('objectStoreNames' in IDBTransaction.prototype)) { + return function (storeNames, ...args) { + const tx = func.call(unwrap(this), storeNames, ...args); + transactionStoreNamesMap.set(tx, storeNames.sort ? storeNames.sort() : [storeNames]); + return wrap(tx); + }; + } + // Cursor methods are special, as the behaviour is a little more different to standard IDB. In + // IDB, you advance the cursor and wait for a new 'success' on the IDBRequest that gave you the + // cursor. It's kinda like a promise that can resolve with many values. That doesn't make sense + // with real promises, so each advance methods returns a new promise for the cursor object, or + // undefined if the end of the cursor has been reached. + if (getCursorAdvanceMethods().includes(func)) { + return function (...args) { + // Calling the original function with the proxy as 'this' causes ILLEGAL INVOCATION, so we use + // the original object. + func.apply(unwrap(this), args); + return wrap(cursorRequestMap.get(this)); + }; + } + return function (...args) { + // Calling the original function with the proxy as 'this' causes ILLEGAL INVOCATION, so we use + // the original object. + return wrap(func.apply(unwrap(this), args)); + }; + } + function transformCachableValue(value) { + if (typeof value === 'function') return wrapFunction(value); + // This doesn't return, it just creates a 'done' promise for the transaction, + // which is later returned for transaction.done (see idbObjectHandler). + if (value instanceof IDBTransaction) cacheDonePromiseForTransaction(value); + if (instanceOfAny(value, getIdbProxyableTypes())) return new Proxy(value, idbProxyTraps); + // Return the same value back if we're not going to transform it. + return value; + } + function wrap(value) { + // We sometimes generate multiple promises from a single IDBRequest (eg when cursoring), because + // IDB is weird and a single IDBRequest can yield many responses, so these can't be cached. + if (value instanceof IDBRequest) return promisifyRequest(value); + // If we've already transformed this value before, reuse the transformed value. + // This is faster, but it also provides object equality. + if (transformCache.has(value)) return transformCache.get(value); + const newValue = transformCachableValue(value); + // Not all types are transformed. + // These may be primitive types, so they can't be WeakMap keys. + if (newValue !== value) { + transformCache.set(value, newValue); + reverseTransformCache.set(newValue, value); + } + return newValue; + } + const unwrap = value => reverseTransformCache.get(value); + + /** + * Open a database. + * + * @param name Name of the database. + * @param version Schema version. + * @param callbacks Additional callbacks. + */ + function openDB(name, version, { + blocked, + upgrade, + blocking, + terminated + } = {}) { + const request = indexedDB.open(name, version); + const openPromise = wrap(request); + if (upgrade) { + request.addEventListener('upgradeneeded', event => { + upgrade(wrap(request.result), event.oldVersion, event.newVersion, wrap(request.transaction), event); + }); + } + if (blocked) { + request.addEventListener('blocked', event => blocked( + // Casting due to https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/1405 + event.oldVersion, event.newVersion, event)); + } + openPromise.then(db => { + if (terminated) db.addEventListener('close', () => terminated()); + if (blocking) { + db.addEventListener('versionchange', event => blocking(event.oldVersion, event.newVersion, event)); + } + }).catch(() => {}); + return openPromise; + } + /** + * Delete a database. + * + * @param name Name of the database. + */ + function deleteDB(name, { + blocked + } = {}) { + const request = indexedDB.deleteDatabase(name); + if (blocked) { + request.addEventListener('blocked', event => blocked( + // Casting due to https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/1405 + event.oldVersion, event)); + } + return wrap(request).then(() => undefined); + } + const readMethods = ['get', 'getKey', 'getAll', 'getAllKeys', 'count']; + const writeMethods = ['put', 'add', 'delete', 'clear']; + const cachedMethods = new Map(); + function getMethod(target, prop) { + if (!(target instanceof IDBDatabase && !(prop in target) && typeof prop === 'string')) { + return; + } + if (cachedMethods.get(prop)) return cachedMethods.get(prop); + const targetFuncName = prop.replace(/FromIndex$/, ''); + const useIndex = prop !== targetFuncName; + const isWrite = writeMethods.includes(targetFuncName); + if ( + // Bail if the target doesn't exist on the target. Eg, getAll isn't in Edge. + !(targetFuncName in (useIndex ? IDBIndex : IDBObjectStore).prototype) || !(isWrite || readMethods.includes(targetFuncName))) { + return; + } + const method = async function (storeName, ...args) { + // isWrite ? 'readwrite' : undefined gzipps better, but fails in Edge :( + const tx = this.transaction(storeName, isWrite ? 'readwrite' : 'readonly'); + let target = tx.store; + if (useIndex) target = target.index(args.shift()); + // Must reject if op rejects. + // If it's a write operation, must reject if tx.done rejects. + // Must reject with op rejection first. + // Must resolve with op value. + // Must handle both promises (no unhandled rejections) + return (await Promise.all([target[targetFuncName](...args), isWrite && tx.done]))[0]; + }; + cachedMethods.set(prop, method); + return method; + } + replaceTraps(oldTraps => _extends({}, oldTraps, { + get: (target, prop, receiver) => getMethod(target, prop) || oldTraps.get(target, prop, receiver), + has: (target, prop) => !!getMethod(target, prop) || oldTraps.has(target, prop) + })); + + // @ts-ignore + try { + self['workbox:expiration:7.3.0'] && _(); + } catch (e) {} + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + const DB_NAME = 'workbox-expiration'; + const CACHE_OBJECT_STORE = 'cache-entries'; + const normalizeURL = unNormalizedUrl => { + const url = new URL(unNormalizedUrl, location.href); + url.hash = ''; + return url.href; + }; + /** + * Returns the timestamp model. + * + * @private + */ + class CacheTimestampsModel { + /** + * + * @param {string} cacheName + * + * @private + */ + constructor(cacheName) { + this._db = null; + this._cacheName = cacheName; + } + /** + * Performs an upgrade of indexedDB. + * + * @param {IDBPDatabase} db + * + * @private + */ + _upgradeDb(db) { + // TODO(philipwalton): EdgeHTML doesn't support arrays as a keyPath, so we + // have to use the `id` keyPath here and create our own values (a + // concatenation of `url + cacheName`) instead of simply using + // `keyPath: ['url', 'cacheName']`, which is supported in other browsers. + const objStore = db.createObjectStore(CACHE_OBJECT_STORE, { + keyPath: 'id' + }); + // TODO(philipwalton): once we don't have to support EdgeHTML, we can + // create a single index with the keyPath `['cacheName', 'timestamp']` + // instead of doing both these indexes. + objStore.createIndex('cacheName', 'cacheName', { + unique: false + }); + objStore.createIndex('timestamp', 'timestamp', { + unique: false + }); + } + /** + * Performs an upgrade of indexedDB and deletes deprecated DBs. + * + * @param {IDBPDatabase} db + * + * @private + */ + _upgradeDbAndDeleteOldDbs(db) { + this._upgradeDb(db); + if (this._cacheName) { + void deleteDB(this._cacheName); + } + } + /** + * @param {string} url + * @param {number} timestamp + * + * @private + */ + async setTimestamp(url, timestamp) { + url = normalizeURL(url); + const entry = { + url, + timestamp, + cacheName: this._cacheName, + // Creating an ID from the URL and cache name won't be necessary once + // Edge switches to Chromium and all browsers we support work with + // array keyPaths. + id: this._getId(url) + }; + const db = await this.getDb(); + const tx = db.transaction(CACHE_OBJECT_STORE, 'readwrite', { + durability: 'relaxed' + }); + await tx.store.put(entry); + await tx.done; + } + /** + * Returns the timestamp stored for a given URL. + * + * @param {string} url + * @return {number | undefined} + * + * @private + */ + async getTimestamp(url) { + const db = await this.getDb(); + const entry = await db.get(CACHE_OBJECT_STORE, this._getId(url)); + return entry === null || entry === void 0 ? void 0 : entry.timestamp; + } + /** + * Iterates through all the entries in the object store (from newest to + * oldest) and removes entries once either `maxCount` is reached or the + * entry's timestamp is less than `minTimestamp`. + * + * @param {number} minTimestamp + * @param {number} maxCount + * @return {Array} + * + * @private + */ + async expireEntries(minTimestamp, maxCount) { + const db = await this.getDb(); + let cursor = await db.transaction(CACHE_OBJECT_STORE).store.index('timestamp').openCursor(null, 'prev'); + const entriesToDelete = []; + let entriesNotDeletedCount = 0; + while (cursor) { + const result = cursor.value; + // TODO(philipwalton): once we can use a multi-key index, we + // won't have to check `cacheName` here. + if (result.cacheName === this._cacheName) { + // Delete an entry if it's older than the max age or + // if we already have the max number allowed. + if (minTimestamp && result.timestamp < minTimestamp || maxCount && entriesNotDeletedCount >= maxCount) { + // TODO(philipwalton): we should be able to delete the + // entry right here, but doing so causes an iteration + // bug in Safari stable (fixed in TP). Instead we can + // store the keys of the entries to delete, and then + // delete the separate transactions. + // https://github.com/GoogleChrome/workbox/issues/1978 + // cursor.delete(); + // We only need to return the URL, not the whole entry. + entriesToDelete.push(cursor.value); + } else { + entriesNotDeletedCount++; + } + } + cursor = await cursor.continue(); + } + // TODO(philipwalton): once the Safari bug in the following issue is fixed, + // we should be able to remove this loop and do the entry deletion in the + // cursor loop above: + // https://github.com/GoogleChrome/workbox/issues/1978 + const urlsDeleted = []; + for (const entry of entriesToDelete) { + await db.delete(CACHE_OBJECT_STORE, entry.id); + urlsDeleted.push(entry.url); + } + return urlsDeleted; + } + /** + * Takes a URL and returns an ID that will be unique in the object store. + * + * @param {string} url + * @return {string} + * + * @private + */ + _getId(url) { + // Creating an ID from the URL and cache name won't be necessary once + // Edge switches to Chromium and all browsers we support work with + // array keyPaths. + return this._cacheName + '|' + normalizeURL(url); + } + /** + * Returns an open connection to the database. + * + * @private + */ + async getDb() { + if (!this._db) { + this._db = await openDB(DB_NAME, 1, { + upgrade: this._upgradeDbAndDeleteOldDbs.bind(this) + }); + } + return this._db; + } + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * The `CacheExpiration` class allows you define an expiration and / or + * limit on the number of responses stored in a + * [`Cache`](https://developer.mozilla.org/en-US/docs/Web/API/Cache). + * + * @memberof workbox-expiration + */ + class CacheExpiration { + /** + * To construct a new CacheExpiration instance you must provide at least + * one of the `config` properties. + * + * @param {string} cacheName Name of the cache to apply restrictions to. + * @param {Object} config + * @param {number} [config.maxEntries] The maximum number of entries to cache. + * Entries used the least will be removed as the maximum is reached. + * @param {number} [config.maxAgeSeconds] The maximum age of an entry before + * it's treated as stale and removed. + * @param {Object} [config.matchOptions] The [`CacheQueryOptions`](https://developer.mozilla.org/en-US/docs/Web/API/Cache/delete#Parameters) + * that will be used when calling `delete()` on the cache. + */ + constructor(cacheName, config = {}) { + this._isRunning = false; + this._rerunRequested = false; + { + finalAssertExports.isType(cacheName, 'string', { + moduleName: 'workbox-expiration', + className: 'CacheExpiration', + funcName: 'constructor', + paramName: 'cacheName' + }); + if (!(config.maxEntries || config.maxAgeSeconds)) { + throw new WorkboxError('max-entries-or-age-required', { + moduleName: 'workbox-expiration', + className: 'CacheExpiration', + funcName: 'constructor' + }); + } + if (config.maxEntries) { + finalAssertExports.isType(config.maxEntries, 'number', { + moduleName: 'workbox-expiration', + className: 'CacheExpiration', + funcName: 'constructor', + paramName: 'config.maxEntries' + }); + } + if (config.maxAgeSeconds) { + finalAssertExports.isType(config.maxAgeSeconds, 'number', { + moduleName: 'workbox-expiration', + className: 'CacheExpiration', + funcName: 'constructor', + paramName: 'config.maxAgeSeconds' + }); + } + } + this._maxEntries = config.maxEntries; + this._maxAgeSeconds = config.maxAgeSeconds; + this._matchOptions = config.matchOptions; + this._cacheName = cacheName; + this._timestampModel = new CacheTimestampsModel(cacheName); + } + /** + * Expires entries for the given cache and given criteria. + */ + async expireEntries() { + if (this._isRunning) { + this._rerunRequested = true; + return; + } + this._isRunning = true; + const minTimestamp = this._maxAgeSeconds ? Date.now() - this._maxAgeSeconds * 1000 : 0; + const urlsExpired = await this._timestampModel.expireEntries(minTimestamp, this._maxEntries); + // Delete URLs from the cache + const cache = await self.caches.open(this._cacheName); + for (const url of urlsExpired) { + await cache.delete(url, this._matchOptions); + } + { + if (urlsExpired.length > 0) { + logger.groupCollapsed(`Expired ${urlsExpired.length} ` + `${urlsExpired.length === 1 ? 'entry' : 'entries'} and removed ` + `${urlsExpired.length === 1 ? 'it' : 'them'} from the ` + `'${this._cacheName}' cache.`); + logger.log(`Expired the following ${urlsExpired.length === 1 ? 'URL' : 'URLs'}:`); + urlsExpired.forEach(url => logger.log(` ${url}`)); + logger.groupEnd(); + } else { + logger.debug(`Cache expiration ran and found no entries to remove.`); + } + } + this._isRunning = false; + if (this._rerunRequested) { + this._rerunRequested = false; + dontWaitFor(this.expireEntries()); + } + } + /** + * Update the timestamp for the given URL. This ensures the when + * removing entries based on maximum entries, most recently used + * is accurate or when expiring, the timestamp is up-to-date. + * + * @param {string} url + */ + async updateTimestamp(url) { + { + finalAssertExports.isType(url, 'string', { + moduleName: 'workbox-expiration', + className: 'CacheExpiration', + funcName: 'updateTimestamp', + paramName: 'url' + }); + } + await this._timestampModel.setTimestamp(url, Date.now()); + } + /** + * Can be used to check if a URL has expired or not before it's used. + * + * This requires a look up from IndexedDB, so can be slow. + * + * Note: This method will not remove the cached entry, call + * `expireEntries()` to remove indexedDB and Cache entries. + * + * @param {string} url + * @return {boolean} + */ + async isURLExpired(url) { + if (!this._maxAgeSeconds) { + { + throw new WorkboxError(`expired-test-without-max-age`, { + methodName: 'isURLExpired', + paramName: 'maxAgeSeconds' + }); + } + } else { + const timestamp = await this._timestampModel.getTimestamp(url); + const expireOlderThan = Date.now() - this._maxAgeSeconds * 1000; + return timestamp !== undefined ? timestamp < expireOlderThan : true; + } + } + /** + * Removes the IndexedDB object store used to keep track of cache expiration + * metadata. + */ + async delete() { + // Make sure we don't attempt another rerun if we're called in the middle of + // a cache expiration. + this._rerunRequested = false; + await this._timestampModel.expireEntries(Infinity); // Expires all. + } + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * This plugin can be used in a `workbox-strategy` to regularly enforce a + * limit on the age and / or the number of cached requests. + * + * It can only be used with `workbox-strategy` instances that have a + * [custom `cacheName` property set](/web/tools/workbox/guides/configure-workbox#custom_cache_names_in_strategies). + * In other words, it can't be used to expire entries in strategy that uses the + * default runtime cache name. + * + * Whenever a cached response is used or updated, this plugin will look + * at the associated cache and remove any old or extra responses. + * + * When using `maxAgeSeconds`, responses may be used *once* after expiring + * because the expiration clean up will not have occurred until *after* the + * cached response has been used. If the response has a "Date" header, then + * a light weight expiration check is performed and the response will not be + * used immediately. + * + * When using `maxEntries`, the entry least-recently requested will be removed + * from the cache first. + * + * @memberof workbox-expiration + */ + class ExpirationPlugin { + /** + * @param {ExpirationPluginOptions} config + * @param {number} [config.maxEntries] The maximum number of entries to cache. + * Entries used the least will be removed as the maximum is reached. + * @param {number} [config.maxAgeSeconds] The maximum age of an entry before + * it's treated as stale and removed. + * @param {Object} [config.matchOptions] The [`CacheQueryOptions`](https://developer.mozilla.org/en-US/docs/Web/API/Cache/delete#Parameters) + * that will be used when calling `delete()` on the cache. + * @param {boolean} [config.purgeOnQuotaError] Whether to opt this cache in to + * automatic deletion if the available storage quota has been exceeded. + */ + constructor(config = {}) { + /** + * A "lifecycle" callback that will be triggered automatically by the + * `workbox-strategies` handlers when a `Response` is about to be returned + * from a [Cache](https://developer.mozilla.org/en-US/docs/Web/API/Cache) to + * the handler. It allows the `Response` to be inspected for freshness and + * prevents it from being used if the `Response`'s `Date` header value is + * older than the configured `maxAgeSeconds`. + * + * @param {Object} options + * @param {string} options.cacheName Name of the cache the response is in. + * @param {Response} options.cachedResponse The `Response` object that's been + * read from a cache and whose freshness should be checked. + * @return {Response} Either the `cachedResponse`, if it's + * fresh, or `null` if the `Response` is older than `maxAgeSeconds`. + * + * @private + */ + this.cachedResponseWillBeUsed = async ({ + event, + request, + cacheName, + cachedResponse + }) => { + if (!cachedResponse) { + return null; + } + const isFresh = this._isResponseDateFresh(cachedResponse); + // Expire entries to ensure that even if the expiration date has + // expired, it'll only be used once. + const cacheExpiration = this._getCacheExpiration(cacheName); + dontWaitFor(cacheExpiration.expireEntries()); + // Update the metadata for the request URL to the current timestamp, + // but don't `await` it as we don't want to block the response. + const updateTimestampDone = cacheExpiration.updateTimestamp(request.url); + if (event) { + try { + event.waitUntil(updateTimestampDone); + } catch (error) { + { + // The event may not be a fetch event; only log the URL if it is. + if ('request' in event) { + logger.warn(`Unable to ensure service worker stays alive when ` + `updating cache entry for ` + `'${getFriendlyURL(event.request.url)}'.`); + } + } + } + } + return isFresh ? cachedResponse : null; + }; + /** + * A "lifecycle" callback that will be triggered automatically by the + * `workbox-strategies` handlers when an entry is added to a cache. + * + * @param {Object} options + * @param {string} options.cacheName Name of the cache that was updated. + * @param {string} options.request The Request for the cached entry. + * + * @private + */ + this.cacheDidUpdate = async ({ + cacheName, + request + }) => { + { + finalAssertExports.isType(cacheName, 'string', { + moduleName: 'workbox-expiration', + className: 'Plugin', + funcName: 'cacheDidUpdate', + paramName: 'cacheName' + }); + finalAssertExports.isInstance(request, Request, { + moduleName: 'workbox-expiration', + className: 'Plugin', + funcName: 'cacheDidUpdate', + paramName: 'request' + }); + } + const cacheExpiration = this._getCacheExpiration(cacheName); + await cacheExpiration.updateTimestamp(request.url); + await cacheExpiration.expireEntries(); + }; + { + if (!(config.maxEntries || config.maxAgeSeconds)) { + throw new WorkboxError('max-entries-or-age-required', { + moduleName: 'workbox-expiration', + className: 'Plugin', + funcName: 'constructor' + }); + } + if (config.maxEntries) { + finalAssertExports.isType(config.maxEntries, 'number', { + moduleName: 'workbox-expiration', + className: 'Plugin', + funcName: 'constructor', + paramName: 'config.maxEntries' + }); + } + if (config.maxAgeSeconds) { + finalAssertExports.isType(config.maxAgeSeconds, 'number', { + moduleName: 'workbox-expiration', + className: 'Plugin', + funcName: 'constructor', + paramName: 'config.maxAgeSeconds' + }); + } + } + this._config = config; + this._maxAgeSeconds = config.maxAgeSeconds; + this._cacheExpirations = new Map(); + if (config.purgeOnQuotaError) { + registerQuotaErrorCallback(() => this.deleteCacheAndMetadata()); + } + } + /** + * A simple helper method to return a CacheExpiration instance for a given + * cache name. + * + * @param {string} cacheName + * @return {CacheExpiration} + * + * @private + */ + _getCacheExpiration(cacheName) { + if (cacheName === cacheNames.getRuntimeName()) { + throw new WorkboxError('expire-custom-caches-only'); + } + let cacheExpiration = this._cacheExpirations.get(cacheName); + if (!cacheExpiration) { + cacheExpiration = new CacheExpiration(cacheName, this._config); + this._cacheExpirations.set(cacheName, cacheExpiration); + } + return cacheExpiration; + } + /** + * @param {Response} cachedResponse + * @return {boolean} + * + * @private + */ + _isResponseDateFresh(cachedResponse) { + if (!this._maxAgeSeconds) { + // We aren't expiring by age, so return true, it's fresh + return true; + } + // Check if the 'date' header will suffice a quick expiration check. + // See https://github.com/GoogleChromeLabs/sw-toolbox/issues/164 for + // discussion. + const dateHeaderTimestamp = this._getDateHeaderTimestamp(cachedResponse); + if (dateHeaderTimestamp === null) { + // Unable to parse date, so assume it's fresh. + return true; + } + // If we have a valid headerTime, then our response is fresh iff the + // headerTime plus maxAgeSeconds is greater than the current time. + const now = Date.now(); + return dateHeaderTimestamp >= now - this._maxAgeSeconds * 1000; + } + /** + * This method will extract the data header and parse it into a useful + * value. + * + * @param {Response} cachedResponse + * @return {number|null} + * + * @private + */ + _getDateHeaderTimestamp(cachedResponse) { + if (!cachedResponse.headers.has('date')) { + return null; + } + const dateHeader = cachedResponse.headers.get('date'); + const parsedDate = new Date(dateHeader); + const headerTime = parsedDate.getTime(); + // If the Date header was invalid for some reason, parsedDate.getTime() + // will return NaN. + if (isNaN(headerTime)) { + return null; + } + return headerTime; + } + /** + * This is a helper method that performs two operations: + * + * - Deletes *all* the underlying Cache instances associated with this plugin + * instance, by calling caches.delete() on your behalf. + * - Deletes the metadata from IndexedDB used to keep track of expiration + * details for each Cache instance. + * + * When using cache expiration, calling this method is preferable to calling + * `caches.delete()` directly, since this will ensure that the IndexedDB + * metadata is also cleanly removed and open IndexedDB instances are deleted. + * + * Note that if you're *not* using cache expiration for a given cache, calling + * `caches.delete()` and passing in the cache's name should be sufficient. + * There is no Workbox-specific method needed for cleanup in that case. + */ + async deleteCacheAndMetadata() { + // Do this one at a time instead of all at once via `Promise.all()` to + // reduce the chance of inconsistency if a promise rejects. + for (const [cacheName, cacheExpiration] of this._cacheExpirations) { + await self.caches.delete(cacheName); + await cacheExpiration.delete(); + } + // Reset this._cacheExpirations to its initial state. + this._cacheExpirations = new Map(); + } + } + + // @ts-ignore + try { + self['workbox:cacheable-response:7.3.0'] && _(); + } catch (e) {} + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * This class allows you to set up rules determining what + * status codes and/or headers need to be present in order for a + * [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) + * to be considered cacheable. + * + * @memberof workbox-cacheable-response + */ + class CacheableResponse { + /** + * To construct a new CacheableResponse instance you must provide at least + * one of the `config` properties. + * + * If both `statuses` and `headers` are specified, then both conditions must + * be met for the `Response` to be considered cacheable. + * + * @param {Object} config + * @param {Array} [config.statuses] One or more status codes that a + * `Response` can have and be considered cacheable. + * @param {Object} [config.headers] A mapping of header names + * and expected values that a `Response` can have and be considered cacheable. + * If multiple headers are provided, only one needs to be present. + */ + constructor(config = {}) { + { + if (!(config.statuses || config.headers)) { + throw new WorkboxError('statuses-or-headers-required', { + moduleName: 'workbox-cacheable-response', + className: 'CacheableResponse', + funcName: 'constructor' + }); + } + if (config.statuses) { + finalAssertExports.isArray(config.statuses, { + moduleName: 'workbox-cacheable-response', + className: 'CacheableResponse', + funcName: 'constructor', + paramName: 'config.statuses' + }); + } + if (config.headers) { + finalAssertExports.isType(config.headers, 'object', { + moduleName: 'workbox-cacheable-response', + className: 'CacheableResponse', + funcName: 'constructor', + paramName: 'config.headers' + }); + } + } + this._statuses = config.statuses; + this._headers = config.headers; + } + /** + * Checks a response to see whether it's cacheable or not, based on this + * object's configuration. + * + * @param {Response} response The response whose cacheability is being + * checked. + * @return {boolean} `true` if the `Response` is cacheable, and `false` + * otherwise. + */ + isResponseCacheable(response) { + { + finalAssertExports.isInstance(response, Response, { + moduleName: 'workbox-cacheable-response', + className: 'CacheableResponse', + funcName: 'isResponseCacheable', + paramName: 'response' + }); + } + let cacheable = true; + if (this._statuses) { + cacheable = this._statuses.includes(response.status); + } + if (this._headers && cacheable) { + cacheable = Object.keys(this._headers).some(headerName => { + return response.headers.get(headerName) === this._headers[headerName]; + }); + } + { + if (!cacheable) { + logger.groupCollapsed(`The request for ` + `'${getFriendlyURL(response.url)}' returned a response that does ` + `not meet the criteria for being cached.`); + logger.groupCollapsed(`View cacheability criteria here.`); + logger.log(`Cacheable statuses: ` + JSON.stringify(this._statuses)); + logger.log(`Cacheable headers: ` + JSON.stringify(this._headers, null, 2)); + logger.groupEnd(); + const logFriendlyHeaders = {}; + response.headers.forEach((value, key) => { + logFriendlyHeaders[key] = value; + }); + logger.groupCollapsed(`View response status and headers here.`); + logger.log(`Response status: ${response.status}`); + logger.log(`Response headers: ` + JSON.stringify(logFriendlyHeaders, null, 2)); + logger.groupEnd(); + logger.groupCollapsed(`View full response details here.`); + logger.log(response.headers); + logger.log(response); + logger.groupEnd(); + logger.groupEnd(); + } + } + return cacheable; + } + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * A class implementing the `cacheWillUpdate` lifecycle callback. This makes it + * easier to add in cacheability checks to requests made via Workbox's built-in + * strategies. + * + * @memberof workbox-cacheable-response + */ + class CacheableResponsePlugin { + /** + * To construct a new CacheableResponsePlugin instance you must provide at + * least one of the `config` properties. + * + * If both `statuses` and `headers` are specified, then both conditions must + * be met for the `Response` to be considered cacheable. + * + * @param {Object} config + * @param {Array} [config.statuses] One or more status codes that a + * `Response` can have and be considered cacheable. + * @param {Object} [config.headers] A mapping of header names + * and expected values that a `Response` can have and be considered cacheable. + * If multiple headers are provided, only one needs to be present. + */ + constructor(config) { + /** + * @param {Object} options + * @param {Response} options.response + * @return {Response|null} + * @private + */ + this.cacheWillUpdate = async ({ + response + }) => { + if (this._cacheableResponse.isResponseCacheable(response)) { + return response; + } + return null; + }; + this._cacheableResponse = new CacheableResponse(config); + } + } + + // @ts-ignore + try { + self['workbox:strategies:7.3.0'] && _(); + } catch (e) {} + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + const cacheOkAndOpaquePlugin = { + /** + * Returns a valid response (to allow caching) if the status is 200 (OK) or + * 0 (opaque). + * + * @param {Object} options + * @param {Response} options.response + * @return {Response|null} + * + * @private + */ + cacheWillUpdate: async ({ + response + }) => { + if (response.status === 200 || response.status === 0) { + return response; + } + return null; + } + }; + + /* + Copyright 2020 Google LLC + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + function stripParams(fullURL, ignoreParams) { + const strippedURL = new URL(fullURL); + for (const param of ignoreParams) { + strippedURL.searchParams.delete(param); + } + return strippedURL.href; + } + /** + * Matches an item in the cache, ignoring specific URL params. This is similar + * to the `ignoreSearch` option, but it allows you to ignore just specific + * params (while continuing to match on the others). + * + * @private + * @param {Cache} cache + * @param {Request} request + * @param {Object} matchOptions + * @param {Array} ignoreParams + * @return {Promise} + */ + async function cacheMatchIgnoreParams(cache, request, ignoreParams, matchOptions) { + const strippedRequestURL = stripParams(request.url, ignoreParams); + // If the request doesn't include any ignored params, match as normal. + if (request.url === strippedRequestURL) { + return cache.match(request, matchOptions); + } + // Otherwise, match by comparing keys + const keysOptions = Object.assign(Object.assign({}, matchOptions), { + ignoreSearch: true + }); + const cacheKeys = await cache.keys(request, keysOptions); + for (const cacheKey of cacheKeys) { + const strippedCacheKeyURL = stripParams(cacheKey.url, ignoreParams); + if (strippedRequestURL === strippedCacheKeyURL) { + return cache.match(cacheKey, matchOptions); + } + } + return; + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * The Deferred class composes Promises in a way that allows for them to be + * resolved or rejected from outside the constructor. In most cases promises + * should be used directly, but Deferreds can be necessary when the logic to + * resolve a promise must be separate. + * + * @private + */ + class Deferred { + /** + * Creates a promise and exposes its resolve and reject functions as methods. + */ + constructor() { + this.promise = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }); + } + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Runs all of the callback functions, one at a time sequentially, in the order + * in which they were registered. + * + * @memberof workbox-core + * @private + */ + async function executeQuotaErrorCallbacks() { + { + logger.log(`About to run ${quotaErrorCallbacks.size} ` + `callbacks to clean up caches.`); + } + for (const callback of quotaErrorCallbacks) { + await callback(); + { + logger.log(callback, 'is complete.'); + } + } + { + logger.log('Finished running callbacks.'); + } + } + + /* + Copyright 2019 Google LLC + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Returns a promise that resolves and the passed number of milliseconds. + * This utility is an async/await-friendly version of `setTimeout`. + * + * @param {number} ms + * @return {Promise} + * @private + */ + function timeout(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + /* + Copyright 2020 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + function toRequest(input) { + return typeof input === 'string' ? new Request(input) : input; + } + /** + * A class created every time a Strategy instance calls + * {@link workbox-strategies.Strategy~handle} or + * {@link workbox-strategies.Strategy~handleAll} that wraps all fetch and + * cache actions around plugin callbacks and keeps track of when the strategy + * is "done" (i.e. all added `event.waitUntil()` promises have resolved). + * + * @memberof workbox-strategies + */ + class StrategyHandler { + /** + * Creates a new instance associated with the passed strategy and event + * that's handling the request. + * + * The constructor also initializes the state that will be passed to each of + * the plugins handling this request. + * + * @param {workbox-strategies.Strategy} strategy + * @param {Object} options + * @param {Request|string} options.request A request to run this strategy for. + * @param {ExtendableEvent} options.event The event associated with the + * request. + * @param {URL} [options.url] + * @param {*} [options.params] The return value from the + * {@link workbox-routing~matchCallback} (if applicable). + */ + constructor(strategy, options) { + this._cacheKeys = {}; + /** + * The request the strategy is performing (passed to the strategy's + * `handle()` or `handleAll()` method). + * @name request + * @instance + * @type {Request} + * @memberof workbox-strategies.StrategyHandler + */ + /** + * The event associated with this request. + * @name event + * @instance + * @type {ExtendableEvent} + * @memberof workbox-strategies.StrategyHandler + */ + /** + * A `URL` instance of `request.url` (if passed to the strategy's + * `handle()` or `handleAll()` method). + * Note: the `url` param will be present if the strategy was invoked + * from a workbox `Route` object. + * @name url + * @instance + * @type {URL|undefined} + * @memberof workbox-strategies.StrategyHandler + */ + /** + * A `param` value (if passed to the strategy's + * `handle()` or `handleAll()` method). + * Note: the `param` param will be present if the strategy was invoked + * from a workbox `Route` object and the + * {@link workbox-routing~matchCallback} returned + * a truthy value (it will be that value). + * @name params + * @instance + * @type {*|undefined} + * @memberof workbox-strategies.StrategyHandler + */ + { + finalAssertExports.isInstance(options.event, ExtendableEvent, { + moduleName: 'workbox-strategies', + className: 'StrategyHandler', + funcName: 'constructor', + paramName: 'options.event' + }); + } + Object.assign(this, options); + this.event = options.event; + this._strategy = strategy; + this._handlerDeferred = new Deferred(); + this._extendLifetimePromises = []; + // Copy the plugins list (since it's mutable on the strategy), + // so any mutations don't affect this handler instance. + this._plugins = [...strategy.plugins]; + this._pluginStateMap = new Map(); + for (const plugin of this._plugins) { + this._pluginStateMap.set(plugin, {}); + } + this.event.waitUntil(this._handlerDeferred.promise); + } + /** + * Fetches a given request (and invokes any applicable plugin callback + * methods) using the `fetchOptions` (for non-navigation requests) and + * `plugins` defined on the `Strategy` object. + * + * The following plugin lifecycle methods are invoked when using this method: + * - `requestWillFetch()` + * - `fetchDidSucceed()` + * - `fetchDidFail()` + * + * @param {Request|string} input The URL or request to fetch. + * @return {Promise} + */ + async fetch(input) { + const { + event + } = this; + let request = toRequest(input); + if (request.mode === 'navigate' && event instanceof FetchEvent && event.preloadResponse) { + const possiblePreloadResponse = await event.preloadResponse; + if (possiblePreloadResponse) { + { + logger.log(`Using a preloaded navigation response for ` + `'${getFriendlyURL(request.url)}'`); + } + return possiblePreloadResponse; + } + } + // If there is a fetchDidFail plugin, we need to save a clone of the + // original request before it's either modified by a requestWillFetch + // plugin or before the original request's body is consumed via fetch(). + const originalRequest = this.hasCallback('fetchDidFail') ? request.clone() : null; + try { + for (const cb of this.iterateCallbacks('requestWillFetch')) { + request = await cb({ + request: request.clone(), + event + }); + } + } catch (err) { + if (err instanceof Error) { + throw new WorkboxError('plugin-error-request-will-fetch', { + thrownErrorMessage: err.message + }); + } + } + // The request can be altered by plugins with `requestWillFetch` making + // the original request (most likely from a `fetch` event) different + // from the Request we make. Pass both to `fetchDidFail` to aid debugging. + const pluginFilteredRequest = request.clone(); + try { + let fetchResponse; + // See https://github.com/GoogleChrome/workbox/issues/1796 + fetchResponse = await fetch(request, request.mode === 'navigate' ? undefined : this._strategy.fetchOptions); + if ("development" !== 'production') { + logger.debug(`Network request for ` + `'${getFriendlyURL(request.url)}' returned a response with ` + `status '${fetchResponse.status}'.`); + } + for (const callback of this.iterateCallbacks('fetchDidSucceed')) { + fetchResponse = await callback({ + event, + request: pluginFilteredRequest, + response: fetchResponse + }); + } + return fetchResponse; + } catch (error) { + { + logger.log(`Network request for ` + `'${getFriendlyURL(request.url)}' threw an error.`, error); + } + // `originalRequest` will only exist if a `fetchDidFail` callback + // is being used (see above). + if (originalRequest) { + await this.runCallbacks('fetchDidFail', { + error: error, + event, + originalRequest: originalRequest.clone(), + request: pluginFilteredRequest.clone() + }); + } + throw error; + } + } + /** + * Calls `this.fetch()` and (in the background) runs `this.cachePut()` on + * the response generated by `this.fetch()`. + * + * The call to `this.cachePut()` automatically invokes `this.waitUntil()`, + * so you do not have to manually call `waitUntil()` on the event. + * + * @param {Request|string} input The request or URL to fetch and cache. + * @return {Promise} + */ + async fetchAndCachePut(input) { + const response = await this.fetch(input); + const responseClone = response.clone(); + void this.waitUntil(this.cachePut(input, responseClone)); + return response; + } + /** + * Matches a request from the cache (and invokes any applicable plugin + * callback methods) using the `cacheName`, `matchOptions`, and `plugins` + * defined on the strategy object. + * + * The following plugin lifecycle methods are invoked when using this method: + * - cacheKeyWillBeUsed() + * - cachedResponseWillBeUsed() + * + * @param {Request|string} key The Request or URL to use as the cache key. + * @return {Promise} A matching response, if found. + */ + async cacheMatch(key) { + const request = toRequest(key); + let cachedResponse; + const { + cacheName, + matchOptions + } = this._strategy; + const effectiveRequest = await this.getCacheKey(request, 'read'); + const multiMatchOptions = Object.assign(Object.assign({}, matchOptions), { + cacheName + }); + cachedResponse = await caches.match(effectiveRequest, multiMatchOptions); + { + if (cachedResponse) { + logger.debug(`Found a cached response in '${cacheName}'.`); + } else { + logger.debug(`No cached response found in '${cacheName}'.`); + } + } + for (const callback of this.iterateCallbacks('cachedResponseWillBeUsed')) { + cachedResponse = (await callback({ + cacheName, + matchOptions, + cachedResponse, + request: effectiveRequest, + event: this.event + })) || undefined; + } + return cachedResponse; + } + /** + * Puts a request/response pair in the cache (and invokes any applicable + * plugin callback methods) using the `cacheName` and `plugins` defined on + * the strategy object. + * + * The following plugin lifecycle methods are invoked when using this method: + * - cacheKeyWillBeUsed() + * - cacheWillUpdate() + * - cacheDidUpdate() + * + * @param {Request|string} key The request or URL to use as the cache key. + * @param {Response} response The response to cache. + * @return {Promise} `false` if a cacheWillUpdate caused the response + * not be cached, and `true` otherwise. + */ + async cachePut(key, response) { + const request = toRequest(key); + // Run in the next task to avoid blocking other cache reads. + // https://github.com/w3c/ServiceWorker/issues/1397 + await timeout(0); + const effectiveRequest = await this.getCacheKey(request, 'write'); + { + if (effectiveRequest.method && effectiveRequest.method !== 'GET') { + throw new WorkboxError('attempt-to-cache-non-get-request', { + url: getFriendlyURL(effectiveRequest.url), + method: effectiveRequest.method + }); + } + // See https://github.com/GoogleChrome/workbox/issues/2818 + const vary = response.headers.get('Vary'); + if (vary) { + logger.debug(`The response for ${getFriendlyURL(effectiveRequest.url)} ` + `has a 'Vary: ${vary}' header. ` + `Consider setting the {ignoreVary: true} option on your strategy ` + `to ensure cache matching and deletion works as expected.`); + } + } + if (!response) { + { + logger.error(`Cannot cache non-existent response for ` + `'${getFriendlyURL(effectiveRequest.url)}'.`); + } + throw new WorkboxError('cache-put-with-no-response', { + url: getFriendlyURL(effectiveRequest.url) + }); + } + const responseToCache = await this._ensureResponseSafeToCache(response); + if (!responseToCache) { + { + logger.debug(`Response '${getFriendlyURL(effectiveRequest.url)}' ` + `will not be cached.`, responseToCache); + } + return false; + } + const { + cacheName, + matchOptions + } = this._strategy; + const cache = await self.caches.open(cacheName); + const hasCacheUpdateCallback = this.hasCallback('cacheDidUpdate'); + const oldResponse = hasCacheUpdateCallback ? await cacheMatchIgnoreParams( + // TODO(philipwalton): the `__WB_REVISION__` param is a precaching + // feature. Consider into ways to only add this behavior if using + // precaching. + cache, effectiveRequest.clone(), ['__WB_REVISION__'], matchOptions) : null; + { + logger.debug(`Updating the '${cacheName}' cache with a new Response ` + `for ${getFriendlyURL(effectiveRequest.url)}.`); + } + try { + await cache.put(effectiveRequest, hasCacheUpdateCallback ? responseToCache.clone() : responseToCache); + } catch (error) { + if (error instanceof Error) { + // See https://developer.mozilla.org/en-US/docs/Web/API/DOMException#exception-QuotaExceededError + if (error.name === 'QuotaExceededError') { + await executeQuotaErrorCallbacks(); + } + throw error; + } + } + for (const callback of this.iterateCallbacks('cacheDidUpdate')) { + await callback({ + cacheName, + oldResponse, + newResponse: responseToCache.clone(), + request: effectiveRequest, + event: this.event + }); + } + return true; + } + /** + * Checks the list of plugins for the `cacheKeyWillBeUsed` callback, and + * executes any of those callbacks found in sequence. The final `Request` + * object returned by the last plugin is treated as the cache key for cache + * reads and/or writes. If no `cacheKeyWillBeUsed` plugin callbacks have + * been registered, the passed request is returned unmodified + * + * @param {Request} request + * @param {string} mode + * @return {Promise} + */ + async getCacheKey(request, mode) { + const key = `${request.url} | ${mode}`; + if (!this._cacheKeys[key]) { + let effectiveRequest = request; + for (const callback of this.iterateCallbacks('cacheKeyWillBeUsed')) { + effectiveRequest = toRequest(await callback({ + mode, + request: effectiveRequest, + event: this.event, + // params has a type any can't change right now. + params: this.params // eslint-disable-line + })); + } + this._cacheKeys[key] = effectiveRequest; + } + return this._cacheKeys[key]; + } + /** + * Returns true if the strategy has at least one plugin with the given + * callback. + * + * @param {string} name The name of the callback to check for. + * @return {boolean} + */ + hasCallback(name) { + for (const plugin of this._strategy.plugins) { + if (name in plugin) { + return true; + } + } + return false; + } + /** + * Runs all plugin callbacks matching the given name, in order, passing the + * given param object (merged ith the current plugin state) as the only + * argument. + * + * Note: since this method runs all plugins, it's not suitable for cases + * where the return value of a callback needs to be applied prior to calling + * the next callback. See + * {@link workbox-strategies.StrategyHandler#iterateCallbacks} + * below for how to handle that case. + * + * @param {string} name The name of the callback to run within each plugin. + * @param {Object} param The object to pass as the first (and only) param + * when executing each callback. This object will be merged with the + * current plugin state prior to callback execution. + */ + async runCallbacks(name, param) { + for (const callback of this.iterateCallbacks(name)) { + // TODO(philipwalton): not sure why `any` is needed. It seems like + // this should work with `as WorkboxPluginCallbackParam[C]`. + await callback(param); + } + } + /** + * Accepts a callback and returns an iterable of matching plugin callbacks, + * where each callback is wrapped with the current handler state (i.e. when + * you call each callback, whatever object parameter you pass it will + * be merged with the plugin's current state). + * + * @param {string} name The name fo the callback to run + * @return {Array} + */ + *iterateCallbacks(name) { + for (const plugin of this._strategy.plugins) { + if (typeof plugin[name] === 'function') { + const state = this._pluginStateMap.get(plugin); + const statefulCallback = param => { + const statefulParam = Object.assign(Object.assign({}, param), { + state + }); + // TODO(philipwalton): not sure why `any` is needed. It seems like + // this should work with `as WorkboxPluginCallbackParam[C]`. + return plugin[name](statefulParam); + }; + yield statefulCallback; + } + } + } + /** + * Adds a promise to the + * [extend lifetime promises]{@link https://w3c.github.io/ServiceWorker/#extendableevent-extend-lifetime-promises} + * of the event associated with the request being handled (usually a + * `FetchEvent`). + * + * Note: you can await + * {@link workbox-strategies.StrategyHandler~doneWaiting} + * to know when all added promises have settled. + * + * @param {Promise} promise A promise to add to the extend lifetime promises + * of the event that triggered the request. + */ + waitUntil(promise) { + this._extendLifetimePromises.push(promise); + return promise; + } + /** + * Returns a promise that resolves once all promises passed to + * {@link workbox-strategies.StrategyHandler~waitUntil} + * have settled. + * + * Note: any work done after `doneWaiting()` settles should be manually + * passed to an event's `waitUntil()` method (not this handler's + * `waitUntil()` method), otherwise the service worker thread may be killed + * prior to your work completing. + */ + async doneWaiting() { + while (this._extendLifetimePromises.length) { + const promises = this._extendLifetimePromises.splice(0); + const result = await Promise.allSettled(promises); + const firstRejection = result.find(i => i.status === 'rejected'); + if (firstRejection) { + throw firstRejection.reason; + } + } + } + /** + * Stops running the strategy and immediately resolves any pending + * `waitUntil()` promises. + */ + destroy() { + this._handlerDeferred.resolve(null); + } + /** + * This method will call cacheWillUpdate on the available plugins (or use + * status === 200) to determine if the Response is safe and valid to cache. + * + * @param {Request} options.request + * @param {Response} options.response + * @return {Promise} + * + * @private + */ + async _ensureResponseSafeToCache(response) { + let responseToCache = response; + let pluginsUsed = false; + for (const callback of this.iterateCallbacks('cacheWillUpdate')) { + responseToCache = (await callback({ + request: this.request, + response: responseToCache, + event: this.event + })) || undefined; + pluginsUsed = true; + if (!responseToCache) { + break; + } + } + if (!pluginsUsed) { + if (responseToCache && responseToCache.status !== 200) { + responseToCache = undefined; + } + { + if (responseToCache) { + if (responseToCache.status !== 200) { + if (responseToCache.status === 0) { + logger.warn(`The response for '${this.request.url}' ` + `is an opaque response. The caching strategy that you're ` + `using will not cache opaque responses by default.`); + } else { + logger.debug(`The response for '${this.request.url}' ` + `returned a status code of '${response.status}' and won't ` + `be cached as a result.`); + } + } + } + } + } + return responseToCache; + } + } + + /* + Copyright 2020 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * An abstract base class that all other strategy classes must extend from: + * + * @memberof workbox-strategies + */ + class Strategy { + /** + * Creates a new instance of the strategy and sets all documented option + * properties as public instance properties. + * + * Note: if a custom strategy class extends the base Strategy class and does + * not need more than these properties, it does not need to define its own + * constructor. + * + * @param {Object} [options] + * @param {string} [options.cacheName] Cache name to store and retrieve + * requests. Defaults to the cache names provided by + * {@link workbox-core.cacheNames}. + * @param {Array} [options.plugins] [Plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins} + * to use in conjunction with this caching strategy. + * @param {Object} [options.fetchOptions] Values passed along to the + * [`init`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters) + * of [non-navigation](https://github.com/GoogleChrome/workbox/issues/1796) + * `fetch()` requests made by this strategy. + * @param {Object} [options.matchOptions] The + * [`CacheQueryOptions`]{@link https://w3c.github.io/ServiceWorker/#dictdef-cachequeryoptions} + * for any `cache.match()` or `cache.put()` calls made by this strategy. + */ + constructor(options = {}) { + /** + * Cache name to store and retrieve + * requests. Defaults to the cache names provided by + * {@link workbox-core.cacheNames}. + * + * @type {string} + */ + this.cacheName = cacheNames.getRuntimeName(options.cacheName); + /** + * The list + * [Plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins} + * used by this strategy. + * + * @type {Array} + */ + this.plugins = options.plugins || []; + /** + * Values passed along to the + * [`init`]{@link https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters} + * of all fetch() requests made by this strategy. + * + * @type {Object} + */ + this.fetchOptions = options.fetchOptions; + /** + * The + * [`CacheQueryOptions`]{@link https://w3c.github.io/ServiceWorker/#dictdef-cachequeryoptions} + * for any `cache.match()` or `cache.put()` calls made by this strategy. + * + * @type {Object} + */ + this.matchOptions = options.matchOptions; + } + /** + * Perform a request strategy and returns a `Promise` that will resolve with + * a `Response`, invoking all relevant plugin callbacks. + * + * When a strategy instance is registered with a Workbox + * {@link workbox-routing.Route}, this method is automatically + * called when the route matches. + * + * Alternatively, this method can be used in a standalone `FetchEvent` + * listener by passing it to `event.respondWith()`. + * + * @param {FetchEvent|Object} options A `FetchEvent` or an object with the + * properties listed below. + * @param {Request|string} options.request A request to run this strategy for. + * @param {ExtendableEvent} options.event The event associated with the + * request. + * @param {URL} [options.url] + * @param {*} [options.params] + */ + handle(options) { + const [responseDone] = this.handleAll(options); + return responseDone; + } + /** + * Similar to {@link workbox-strategies.Strategy~handle}, but + * instead of just returning a `Promise` that resolves to a `Response` it + * it will return an tuple of `[response, done]` promises, where the former + * (`response`) is equivalent to what `handle()` returns, and the latter is a + * Promise that will resolve once any promises that were added to + * `event.waitUntil()` as part of performing the strategy have completed. + * + * You can await the `done` promise to ensure any extra work performed by + * the strategy (usually caching responses) completes successfully. + * + * @param {FetchEvent|Object} options A `FetchEvent` or an object with the + * properties listed below. + * @param {Request|string} options.request A request to run this strategy for. + * @param {ExtendableEvent} options.event The event associated with the + * request. + * @param {URL} [options.url] + * @param {*} [options.params] + * @return {Array} A tuple of [response, done] + * promises that can be used to determine when the response resolves as + * well as when the handler has completed all its work. + */ + handleAll(options) { + // Allow for flexible options to be passed. + if (options instanceof FetchEvent) { + options = { + event: options, + request: options.request + }; + } + const event = options.event; + const request = typeof options.request === 'string' ? new Request(options.request) : options.request; + const params = 'params' in options ? options.params : undefined; + const handler = new StrategyHandler(this, { + event, + request, + params + }); + const responseDone = this._getResponse(handler, request, event); + const handlerDone = this._awaitComplete(responseDone, handler, request, event); + // Return an array of promises, suitable for use with Promise.all(). + return [responseDone, handlerDone]; + } + async _getResponse(handler, request, event) { + await handler.runCallbacks('handlerWillStart', { + event, + request + }); + let response = undefined; + try { + response = await this._handle(request, handler); + // The "official" Strategy subclasses all throw this error automatically, + // but in case a third-party Strategy doesn't, ensure that we have a + // consistent failure when there's no response or an error response. + if (!response || response.type === 'error') { + throw new WorkboxError('no-response', { + url: request.url + }); + } + } catch (error) { + if (error instanceof Error) { + for (const callback of handler.iterateCallbacks('handlerDidError')) { + response = await callback({ + error, + event, + request + }); + if (response) { + break; + } + } + } + if (!response) { + throw error; + } else { + logger.log(`While responding to '${getFriendlyURL(request.url)}', ` + `an ${error instanceof Error ? error.toString() : ''} error occurred. Using a fallback response provided by ` + `a handlerDidError plugin.`); + } + } + for (const callback of handler.iterateCallbacks('handlerWillRespond')) { + response = await callback({ + event, + request, + response + }); + } + return response; + } + async _awaitComplete(responseDone, handler, request, event) { + let response; + let error; + try { + response = await responseDone; + } catch (error) { + // Ignore errors, as response errors should be caught via the `response` + // promise above. The `done` promise will only throw for errors in + // promises passed to `handler.waitUntil()`. + } + try { + await handler.runCallbacks('handlerDidRespond', { + event, + request, + response + }); + await handler.doneWaiting(); + } catch (waitUntilError) { + if (waitUntilError instanceof Error) { + error = waitUntilError; + } + } + await handler.runCallbacks('handlerDidComplete', { + event, + request, + response, + error: error + }); + handler.destroy(); + if (error) { + throw error; + } + } + } + /** + * Classes extending the `Strategy` based class should implement this method, + * and leverage the {@link workbox-strategies.StrategyHandler} + * arg to perform all fetching and cache logic, which will ensure all relevant + * cache, cache options, fetch options and plugins are used (per the current + * strategy instance). + * + * @name _handle + * @instance + * @abstract + * @function + * @param {Request} request + * @param {workbox-strategies.StrategyHandler} handler + * @return {Promise} + * + * @memberof workbox-strategies.Strategy + */ + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + const messages = { + strategyStart: (strategyName, request) => `Using ${strategyName} to respond to '${getFriendlyURL(request.url)}'`, + printFinalResponse: response => { + if (response) { + logger.groupCollapsed(`View the final response here.`); + logger.log(response || '[No response returned]'); + logger.groupEnd(); + } + } + }; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * An implementation of a + * [network first](https://developer.chrome.com/docs/workbox/caching-strategies-overview/#network-first-falling-back-to-cache) + * request strategy. + * + * By default, this strategy will cache responses with a 200 status code as + * well as [opaque responses](https://developer.chrome.com/docs/workbox/caching-resources-during-runtime/#opaque-responses). + * Opaque responses are are cross-origin requests where the response doesn't + * support [CORS](https://enable-cors.org/). + * + * If the network request fails, and there is no cache match, this will throw + * a `WorkboxError` exception. + * + * @extends workbox-strategies.Strategy + * @memberof workbox-strategies + */ + class NetworkFirst extends Strategy { + /** + * @param {Object} [options] + * @param {string} [options.cacheName] Cache name to store and retrieve + * requests. Defaults to cache names provided by + * {@link workbox-core.cacheNames}. + * @param {Array} [options.plugins] [Plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins} + * to use in conjunction with this caching strategy. + * @param {Object} [options.fetchOptions] Values passed along to the + * [`init`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters) + * of [non-navigation](https://github.com/GoogleChrome/workbox/issues/1796) + * `fetch()` requests made by this strategy. + * @param {Object} [options.matchOptions] [`CacheQueryOptions`](https://w3c.github.io/ServiceWorker/#dictdef-cachequeryoptions) + * @param {number} [options.networkTimeoutSeconds] If set, any network requests + * that fail to respond within the timeout will fallback to the cache. + * + * This option can be used to combat + * "[lie-fi]{@link https://developers.google.com/web/fundamentals/performance/poor-connectivity/#lie-fi}" + * scenarios. + */ + constructor(options = {}) { + super(options); + // If this instance contains no plugins with a 'cacheWillUpdate' callback, + // prepend the `cacheOkAndOpaquePlugin` plugin to the plugins list. + if (!this.plugins.some(p => 'cacheWillUpdate' in p)) { + this.plugins.unshift(cacheOkAndOpaquePlugin); + } + this._networkTimeoutSeconds = options.networkTimeoutSeconds || 0; + { + if (this._networkTimeoutSeconds) { + finalAssertExports.isType(this._networkTimeoutSeconds, 'number', { + moduleName: 'workbox-strategies', + className: this.constructor.name, + funcName: 'constructor', + paramName: 'networkTimeoutSeconds' + }); + } + } + } + /** + * @private + * @param {Request|string} request A request to run this strategy for. + * @param {workbox-strategies.StrategyHandler} handler The event that + * triggered the request. + * @return {Promise} + */ + async _handle(request, handler) { + const logs = []; + { + finalAssertExports.isInstance(request, Request, { + moduleName: 'workbox-strategies', + className: this.constructor.name, + funcName: 'handle', + paramName: 'makeRequest' + }); + } + const promises = []; + let timeoutId; + if (this._networkTimeoutSeconds) { + const { + id, + promise + } = this._getTimeoutPromise({ + request, + logs, + handler + }); + timeoutId = id; + promises.push(promise); + } + const networkPromise = this._getNetworkPromise({ + timeoutId, + request, + logs, + handler + }); + promises.push(networkPromise); + const response = await handler.waitUntil((async () => { + // Promise.race() will resolve as soon as the first promise resolves. + return (await handler.waitUntil(Promise.race(promises))) || ( + // If Promise.race() resolved with null, it might be due to a network + // timeout + a cache miss. If that were to happen, we'd rather wait until + // the networkPromise resolves instead of returning null. + // Note that it's fine to await an already-resolved promise, so we don't + // have to check to see if it's still "in flight". + await networkPromise); + })()); + { + logger.groupCollapsed(messages.strategyStart(this.constructor.name, request)); + for (const log of logs) { + logger.log(log); + } + messages.printFinalResponse(response); + logger.groupEnd(); + } + if (!response) { + throw new WorkboxError('no-response', { + url: request.url + }); + } + return response; + } + /** + * @param {Object} options + * @param {Request} options.request + * @param {Array} options.logs A reference to the logs array + * @param {Event} options.event + * @return {Promise} + * + * @private + */ + _getTimeoutPromise({ + request, + logs, + handler + }) { + let timeoutId; + const timeoutPromise = new Promise(resolve => { + const onNetworkTimeout = async () => { + { + logs.push(`Timing out the network response at ` + `${this._networkTimeoutSeconds} seconds.`); + } + resolve(await handler.cacheMatch(request)); + }; + timeoutId = setTimeout(onNetworkTimeout, this._networkTimeoutSeconds * 1000); + }); + return { + promise: timeoutPromise, + id: timeoutId + }; + } + /** + * @param {Object} options + * @param {number|undefined} options.timeoutId + * @param {Request} options.request + * @param {Array} options.logs A reference to the logs Array. + * @param {Event} options.event + * @return {Promise} + * + * @private + */ + async _getNetworkPromise({ + timeoutId, + request, + logs, + handler + }) { + let error; + let response; + try { + response = await handler.fetchAndCachePut(request); + } catch (fetchError) { + if (fetchError instanceof Error) { + error = fetchError; + } + } + if (timeoutId) { + clearTimeout(timeoutId); + } + { + if (response) { + logs.push(`Got response from network.`); + } else { + logs.push(`Unable to get a response from the network. Will respond ` + `with a cached response.`); + } + } + if (error || !response) { + response = await handler.cacheMatch(request); + { + if (response) { + logs.push(`Found a cached response in the '${this.cacheName}'` + ` cache.`); + } else { + logs.push(`No response found in the '${this.cacheName}' cache.`); + } + } + } + return response; + } + } + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Claim any currently available clients once the service worker + * becomes active. This is normally used in conjunction with `skipWaiting()`. + * + * @memberof workbox-core + */ + function clientsClaim() { + self.addEventListener('activate', () => self.clients.claim()); + } + + /* + Copyright 2020 Google LLC + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * A utility method that makes it easier to use `event.waitUntil` with + * async functions and return the result. + * + * @param {ExtendableEvent} event + * @param {Function} asyncFn + * @return {Function} + * @private + */ + function waitUntil(event, asyncFn) { + const returnPromise = asyncFn(); + event.waitUntil(returnPromise); + return returnPromise; + } + + // @ts-ignore + try { + self['workbox:precaching:7.3.0'] && _(); + } catch (e) {} + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + // Name of the search parameter used to store revision info. + const REVISION_SEARCH_PARAM = '__WB_REVISION__'; + /** + * Converts a manifest entry into a versioned URL suitable for precaching. + * + * @param {Object|string} entry + * @return {string} A URL with versioning info. + * + * @private + * @memberof workbox-precaching + */ + function createCacheKey(entry) { + if (!entry) { + throw new WorkboxError('add-to-cache-list-unexpected-type', { + entry + }); + } + // If a precache manifest entry is a string, it's assumed to be a versioned + // URL, like '/app.abcd1234.js'. Return as-is. + if (typeof entry === 'string') { + const urlObject = new URL(entry, location.href); + return { + cacheKey: urlObject.href, + url: urlObject.href + }; + } + const { + revision, + url + } = entry; + if (!url) { + throw new WorkboxError('add-to-cache-list-unexpected-type', { + entry + }); + } + // If there's just a URL and no revision, then it's also assumed to be a + // versioned URL. + if (!revision) { + const urlObject = new URL(url, location.href); + return { + cacheKey: urlObject.href, + url: urlObject.href + }; + } + // Otherwise, construct a properly versioned URL using the custom Workbox + // search parameter along with the revision info. + const cacheKeyURL = new URL(url, location.href); + const originalURL = new URL(url, location.href); + cacheKeyURL.searchParams.set(REVISION_SEARCH_PARAM, revision); + return { + cacheKey: cacheKeyURL.href, + url: originalURL.href + }; + } + + /* + Copyright 2020 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * A plugin, designed to be used with PrecacheController, to determine the + * of assets that were updated (or not updated) during the install event. + * + * @private + */ + class PrecacheInstallReportPlugin { + constructor() { + this.updatedURLs = []; + this.notUpdatedURLs = []; + this.handlerWillStart = async ({ + request, + state + }) => { + // TODO: `state` should never be undefined... + if (state) { + state.originalRequest = request; + } + }; + this.cachedResponseWillBeUsed = async ({ + event, + state, + cachedResponse + }) => { + if (event.type === 'install') { + if (state && state.originalRequest && state.originalRequest instanceof Request) { + // TODO: `state` should never be undefined... + const url = state.originalRequest.url; + if (cachedResponse) { + this.notUpdatedURLs.push(url); + } else { + this.updatedURLs.push(url); + } + } + } + return cachedResponse; + }; + } + } + + /* + Copyright 2020 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * A plugin, designed to be used with PrecacheController, to translate URLs into + * the corresponding cache key, based on the current revision info. + * + * @private + */ + class PrecacheCacheKeyPlugin { + constructor({ + precacheController + }) { + this.cacheKeyWillBeUsed = async ({ + request, + params + }) => { + // Params is type any, can't change right now. + /* eslint-disable */ + const cacheKey = (params === null || params === void 0 ? void 0 : params.cacheKey) || this._precacheController.getCacheKeyForURL(request.url); + /* eslint-enable */ + return cacheKey ? new Request(cacheKey, { + headers: request.headers + }) : request; + }; + this._precacheController = precacheController; + } + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * @param {string} groupTitle + * @param {Array} deletedURLs + * + * @private + */ + const logGroup = (groupTitle, deletedURLs) => { + logger.groupCollapsed(groupTitle); + for (const url of deletedURLs) { + logger.log(url); + } + logger.groupEnd(); + }; + /** + * @param {Array} deletedURLs + * + * @private + * @memberof workbox-precaching + */ + function printCleanupDetails(deletedURLs) { + const deletionCount = deletedURLs.length; + if (deletionCount > 0) { + logger.groupCollapsed(`During precaching cleanup, ` + `${deletionCount} cached ` + `request${deletionCount === 1 ? ' was' : 's were'} deleted.`); + logGroup('Deleted Cache Requests', deletedURLs); + logger.groupEnd(); + } + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * @param {string} groupTitle + * @param {Array} urls + * + * @private + */ + function _nestedGroup(groupTitle, urls) { + if (urls.length === 0) { + return; + } + logger.groupCollapsed(groupTitle); + for (const url of urls) { + logger.log(url); + } + logger.groupEnd(); + } + /** + * @param {Array} urlsToPrecache + * @param {Array} urlsAlreadyPrecached + * + * @private + * @memberof workbox-precaching + */ + function printInstallDetails(urlsToPrecache, urlsAlreadyPrecached) { + const precachedCount = urlsToPrecache.length; + const alreadyPrecachedCount = urlsAlreadyPrecached.length; + if (precachedCount || alreadyPrecachedCount) { + let message = `Precaching ${precachedCount} file${precachedCount === 1 ? '' : 's'}.`; + if (alreadyPrecachedCount > 0) { + message += ` ${alreadyPrecachedCount} ` + `file${alreadyPrecachedCount === 1 ? ' is' : 's are'} already cached.`; + } + logger.groupCollapsed(message); + _nestedGroup(`View newly precached URLs.`, urlsToPrecache); + _nestedGroup(`View previously precached URLs.`, urlsAlreadyPrecached); + logger.groupEnd(); + } + } + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + let supportStatus; + /** + * A utility function that determines whether the current browser supports + * constructing a new `Response` from a `response.body` stream. + * + * @return {boolean} `true`, if the current browser can successfully + * construct a `Response` from a `response.body` stream, `false` otherwise. + * + * @private + */ + function canConstructResponseFromBodyStream() { + if (supportStatus === undefined) { + const testResponse = new Response(''); + if ('body' in testResponse) { + try { + new Response(testResponse.body); + supportStatus = true; + } catch (error) { + supportStatus = false; + } + } + supportStatus = false; + } + return supportStatus; + } + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Allows developers to copy a response and modify its `headers`, `status`, + * or `statusText` values (the values settable via a + * [`ResponseInit`]{@link https://developer.mozilla.org/en-US/docs/Web/API/Response/Response#Syntax} + * object in the constructor). + * To modify these values, pass a function as the second argument. That + * function will be invoked with a single object with the response properties + * `{headers, status, statusText}`. The return value of this function will + * be used as the `ResponseInit` for the new `Response`. To change the values + * either modify the passed parameter(s) and return it, or return a totally + * new object. + * + * This method is intentionally limited to same-origin responses, regardless of + * whether CORS was used or not. + * + * @param {Response} response + * @param {Function} modifier + * @memberof workbox-core + */ + async function copyResponse(response, modifier) { + let origin = null; + // If response.url isn't set, assume it's cross-origin and keep origin null. + if (response.url) { + const responseURL = new URL(response.url); + origin = responseURL.origin; + } + if (origin !== self.location.origin) { + throw new WorkboxError('cross-origin-copy-response', { + origin + }); + } + const clonedResponse = response.clone(); + // Create a fresh `ResponseInit` object by cloning the headers. + const responseInit = { + headers: new Headers(clonedResponse.headers), + status: clonedResponse.status, + statusText: clonedResponse.statusText + }; + // Apply any user modifications. + const modifiedResponseInit = modifier ? modifier(responseInit) : responseInit; + // Create the new response from the body stream and `ResponseInit` + // modifications. Note: not all browsers support the Response.body stream, + // so fall back to reading the entire body into memory as a blob. + const body = canConstructResponseFromBodyStream() ? clonedResponse.body : await clonedResponse.blob(); + return new Response(body, modifiedResponseInit); + } + + /* + Copyright 2020 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * A {@link workbox-strategies.Strategy} implementation + * specifically designed to work with + * {@link workbox-precaching.PrecacheController} + * to both cache and fetch precached assets. + * + * Note: an instance of this class is created automatically when creating a + * `PrecacheController`; it's generally not necessary to create this yourself. + * + * @extends workbox-strategies.Strategy + * @memberof workbox-precaching + */ + class PrecacheStrategy extends Strategy { + /** + * + * @param {Object} [options] + * @param {string} [options.cacheName] Cache name to store and retrieve + * requests. Defaults to the cache names provided by + * {@link workbox-core.cacheNames}. + * @param {Array} [options.plugins] {@link https://developers.google.com/web/tools/workbox/guides/using-plugins|Plugins} + * to use in conjunction with this caching strategy. + * @param {Object} [options.fetchOptions] Values passed along to the + * {@link https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters|init} + * of all fetch() requests made by this strategy. + * @param {Object} [options.matchOptions] The + * {@link https://w3c.github.io/ServiceWorker/#dictdef-cachequeryoptions|CacheQueryOptions} + * for any `cache.match()` or `cache.put()` calls made by this strategy. + * @param {boolean} [options.fallbackToNetwork=true] Whether to attempt to + * get the response from the network if there's a precache miss. + */ + constructor(options = {}) { + options.cacheName = cacheNames.getPrecacheName(options.cacheName); + super(options); + this._fallbackToNetwork = options.fallbackToNetwork === false ? false : true; + // Redirected responses cannot be used to satisfy a navigation request, so + // any redirected response must be "copied" rather than cloned, so the new + // response doesn't contain the `redirected` flag. See: + // https://bugs.chromium.org/p/chromium/issues/detail?id=669363&desc=2#c1 + this.plugins.push(PrecacheStrategy.copyRedirectedCacheableResponsesPlugin); + } + /** + * @private + * @param {Request|string} request A request to run this strategy for. + * @param {workbox-strategies.StrategyHandler} handler The event that + * triggered the request. + * @return {Promise} + */ + async _handle(request, handler) { + const response = await handler.cacheMatch(request); + if (response) { + return response; + } + // If this is an `install` event for an entry that isn't already cached, + // then populate the cache. + if (handler.event && handler.event.type === 'install') { + return await this._handleInstall(request, handler); + } + // Getting here means something went wrong. An entry that should have been + // precached wasn't found in the cache. + return await this._handleFetch(request, handler); + } + async _handleFetch(request, handler) { + let response; + const params = handler.params || {}; + // Fall back to the network if we're configured to do so. + if (this._fallbackToNetwork) { + { + logger.warn(`The precached response for ` + `${getFriendlyURL(request.url)} in ${this.cacheName} was not ` + `found. Falling back to the network.`); + } + const integrityInManifest = params.integrity; + const integrityInRequest = request.integrity; + const noIntegrityConflict = !integrityInRequest || integrityInRequest === integrityInManifest; + // Do not add integrity if the original request is no-cors + // See https://github.com/GoogleChrome/workbox/issues/3096 + response = await handler.fetch(new Request(request, { + integrity: request.mode !== 'no-cors' ? integrityInRequest || integrityInManifest : undefined + })); + // It's only "safe" to repair the cache if we're using SRI to guarantee + // that the response matches the precache manifest's expectations, + // and there's either a) no integrity property in the incoming request + // or b) there is an integrity, and it matches the precache manifest. + // See https://github.com/GoogleChrome/workbox/issues/2858 + // Also if the original request users no-cors we don't use integrity. + // See https://github.com/GoogleChrome/workbox/issues/3096 + if (integrityInManifest && noIntegrityConflict && request.mode !== 'no-cors') { + this._useDefaultCacheabilityPluginIfNeeded(); + const wasCached = await handler.cachePut(request, response.clone()); + { + if (wasCached) { + logger.log(`A response for ${getFriendlyURL(request.url)} ` + `was used to "repair" the precache.`); + } + } + } + } else { + // This shouldn't normally happen, but there are edge cases: + // https://github.com/GoogleChrome/workbox/issues/1441 + throw new WorkboxError('missing-precache-entry', { + cacheName: this.cacheName, + url: request.url + }); + } + { + const cacheKey = params.cacheKey || (await handler.getCacheKey(request, 'read')); + // Workbox is going to handle the route. + // print the routing details to the console. + logger.groupCollapsed(`Precaching is responding to: ` + getFriendlyURL(request.url)); + logger.log(`Serving the precached url: ${getFriendlyURL(cacheKey instanceof Request ? cacheKey.url : cacheKey)}`); + logger.groupCollapsed(`View request details here.`); + logger.log(request); + logger.groupEnd(); + logger.groupCollapsed(`View response details here.`); + logger.log(response); + logger.groupEnd(); + logger.groupEnd(); + } + return response; + } + async _handleInstall(request, handler) { + this._useDefaultCacheabilityPluginIfNeeded(); + const response = await handler.fetch(request); + // Make sure we defer cachePut() until after we know the response + // should be cached; see https://github.com/GoogleChrome/workbox/issues/2737 + const wasCached = await handler.cachePut(request, response.clone()); + if (!wasCached) { + // Throwing here will lead to the `install` handler failing, which + // we want to do if *any* of the responses aren't safe to cache. + throw new WorkboxError('bad-precaching-response', { + url: request.url, + status: response.status + }); + } + return response; + } + /** + * This method is complex, as there a number of things to account for: + * + * The `plugins` array can be set at construction, and/or it might be added to + * to at any time before the strategy is used. + * + * At the time the strategy is used (i.e. during an `install` event), there + * needs to be at least one plugin that implements `cacheWillUpdate` in the + * array, other than `copyRedirectedCacheableResponsesPlugin`. + * + * - If this method is called and there are no suitable `cacheWillUpdate` + * plugins, we need to add `defaultPrecacheCacheabilityPlugin`. + * + * - If this method is called and there is exactly one `cacheWillUpdate`, then + * we don't have to do anything (this might be a previously added + * `defaultPrecacheCacheabilityPlugin`, or it might be a custom plugin). + * + * - If this method is called and there is more than one `cacheWillUpdate`, + * then we need to check if one is `defaultPrecacheCacheabilityPlugin`. If so, + * we need to remove it. (This situation is unlikely, but it could happen if + * the strategy is used multiple times, the first without a `cacheWillUpdate`, + * and then later on after manually adding a custom `cacheWillUpdate`.) + * + * See https://github.com/GoogleChrome/workbox/issues/2737 for more context. + * + * @private + */ + _useDefaultCacheabilityPluginIfNeeded() { + let defaultPluginIndex = null; + let cacheWillUpdatePluginCount = 0; + for (const [index, plugin] of this.plugins.entries()) { + // Ignore the copy redirected plugin when determining what to do. + if (plugin === PrecacheStrategy.copyRedirectedCacheableResponsesPlugin) { + continue; + } + // Save the default plugin's index, in case it needs to be removed. + if (plugin === PrecacheStrategy.defaultPrecacheCacheabilityPlugin) { + defaultPluginIndex = index; + } + if (plugin.cacheWillUpdate) { + cacheWillUpdatePluginCount++; + } + } + if (cacheWillUpdatePluginCount === 0) { + this.plugins.push(PrecacheStrategy.defaultPrecacheCacheabilityPlugin); + } else if (cacheWillUpdatePluginCount > 1 && defaultPluginIndex !== null) { + // Only remove the default plugin; multiple custom plugins are allowed. + this.plugins.splice(defaultPluginIndex, 1); + } + // Nothing needs to be done if cacheWillUpdatePluginCount is 1 + } + } + PrecacheStrategy.defaultPrecacheCacheabilityPlugin = { + async cacheWillUpdate({ + response + }) { + if (!response || response.status >= 400) { + return null; + } + return response; + } + }; + PrecacheStrategy.copyRedirectedCacheableResponsesPlugin = { + async cacheWillUpdate({ + response + }) { + return response.redirected ? await copyResponse(response) : response; + } + }; + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Performs efficient precaching of assets. + * + * @memberof workbox-precaching + */ + class PrecacheController { + /** + * Create a new PrecacheController. + * + * @param {Object} [options] + * @param {string} [options.cacheName] The cache to use for precaching. + * @param {string} [options.plugins] Plugins to use when precaching as well + * as responding to fetch events for precached assets. + * @param {boolean} [options.fallbackToNetwork=true] Whether to attempt to + * get the response from the network if there's a precache miss. + */ + constructor({ + cacheName, + plugins = [], + fallbackToNetwork = true + } = {}) { + this._urlsToCacheKeys = new Map(); + this._urlsToCacheModes = new Map(); + this._cacheKeysToIntegrities = new Map(); + this._strategy = new PrecacheStrategy({ + cacheName: cacheNames.getPrecacheName(cacheName), + plugins: [...plugins, new PrecacheCacheKeyPlugin({ + precacheController: this + })], + fallbackToNetwork + }); + // Bind the install and activate methods to the instance. + this.install = this.install.bind(this); + this.activate = this.activate.bind(this); + } + /** + * @type {workbox-precaching.PrecacheStrategy} The strategy created by this controller and + * used to cache assets and respond to fetch events. + */ + get strategy() { + return this._strategy; + } + /** + * Adds items to the precache list, removing any duplicates and + * stores the files in the + * {@link workbox-core.cacheNames|"precache cache"} when the service + * worker installs. + * + * This method can be called multiple times. + * + * @param {Array} [entries=[]] Array of entries to precache. + */ + precache(entries) { + this.addToCacheList(entries); + if (!this._installAndActiveListenersAdded) { + self.addEventListener('install', this.install); + self.addEventListener('activate', this.activate); + this._installAndActiveListenersAdded = true; + } + } + /** + * This method will add items to the precache list, removing duplicates + * and ensuring the information is valid. + * + * @param {Array} entries + * Array of entries to precache. + */ + addToCacheList(entries) { + { + finalAssertExports.isArray(entries, { + moduleName: 'workbox-precaching', + className: 'PrecacheController', + funcName: 'addToCacheList', + paramName: 'entries' + }); + } + const urlsToWarnAbout = []; + for (const entry of entries) { + // See https://github.com/GoogleChrome/workbox/issues/2259 + if (typeof entry === 'string') { + urlsToWarnAbout.push(entry); + } else if (entry && entry.revision === undefined) { + urlsToWarnAbout.push(entry.url); + } + const { + cacheKey, + url + } = createCacheKey(entry); + const cacheMode = typeof entry !== 'string' && entry.revision ? 'reload' : 'default'; + if (this._urlsToCacheKeys.has(url) && this._urlsToCacheKeys.get(url) !== cacheKey) { + throw new WorkboxError('add-to-cache-list-conflicting-entries', { + firstEntry: this._urlsToCacheKeys.get(url), + secondEntry: cacheKey + }); + } + if (typeof entry !== 'string' && entry.integrity) { + if (this._cacheKeysToIntegrities.has(cacheKey) && this._cacheKeysToIntegrities.get(cacheKey) !== entry.integrity) { + throw new WorkboxError('add-to-cache-list-conflicting-integrities', { + url + }); + } + this._cacheKeysToIntegrities.set(cacheKey, entry.integrity); + } + this._urlsToCacheKeys.set(url, cacheKey); + this._urlsToCacheModes.set(url, cacheMode); + if (urlsToWarnAbout.length > 0) { + const warningMessage = `Workbox is precaching URLs without revision ` + `info: ${urlsToWarnAbout.join(', ')}\nThis is generally NOT safe. ` + `Learn more at https://bit.ly/wb-precache`; + { + logger.warn(warningMessage); + } + } + } + } + /** + * Precaches new and updated assets. Call this method from the service worker + * install event. + * + * Note: this method calls `event.waitUntil()` for you, so you do not need + * to call it yourself in your event handlers. + * + * @param {ExtendableEvent} event + * @return {Promise} + */ + install(event) { + // waitUntil returns Promise + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return waitUntil(event, async () => { + const installReportPlugin = new PrecacheInstallReportPlugin(); + this.strategy.plugins.push(installReportPlugin); + // Cache entries one at a time. + // See https://github.com/GoogleChrome/workbox/issues/2528 + for (const [url, cacheKey] of this._urlsToCacheKeys) { + const integrity = this._cacheKeysToIntegrities.get(cacheKey); + const cacheMode = this._urlsToCacheModes.get(url); + const request = new Request(url, { + integrity, + cache: cacheMode, + credentials: 'same-origin' + }); + await Promise.all(this.strategy.handleAll({ + params: { + cacheKey + }, + request, + event + })); + } + const { + updatedURLs, + notUpdatedURLs + } = installReportPlugin; + { + printInstallDetails(updatedURLs, notUpdatedURLs); + } + return { + updatedURLs, + notUpdatedURLs + }; + }); + } + /** + * Deletes assets that are no longer present in the current precache manifest. + * Call this method from the service worker activate event. + * + * Note: this method calls `event.waitUntil()` for you, so you do not need + * to call it yourself in your event handlers. + * + * @param {ExtendableEvent} event + * @return {Promise} + */ + activate(event) { + // waitUntil returns Promise + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return waitUntil(event, async () => { + const cache = await self.caches.open(this.strategy.cacheName); + const currentlyCachedRequests = await cache.keys(); + const expectedCacheKeys = new Set(this._urlsToCacheKeys.values()); + const deletedURLs = []; + for (const request of currentlyCachedRequests) { + if (!expectedCacheKeys.has(request.url)) { + await cache.delete(request); + deletedURLs.push(request.url); + } + } + { + printCleanupDetails(deletedURLs); + } + return { + deletedURLs + }; + }); + } + /** + * Returns a mapping of a precached URL to the corresponding cache key, taking + * into account the revision information for the URL. + * + * @return {Map} A URL to cache key mapping. + */ + getURLsToCacheKeys() { + return this._urlsToCacheKeys; + } + /** + * Returns a list of all the URLs that have been precached by the current + * service worker. + * + * @return {Array} The precached URLs. + */ + getCachedURLs() { + return [...this._urlsToCacheKeys.keys()]; + } + /** + * Returns the cache key used for storing a given URL. If that URL is + * unversioned, like `/index.html', then the cache key will be the original + * URL with a search parameter appended to it. + * + * @param {string} url A URL whose cache key you want to look up. + * @return {string} The versioned URL that corresponds to a cache key + * for the original URL, or undefined if that URL isn't precached. + */ + getCacheKeyForURL(url) { + const urlObject = new URL(url, location.href); + return this._urlsToCacheKeys.get(urlObject.href); + } + /** + * @param {string} url A cache key whose SRI you want to look up. + * @return {string} The subresource integrity associated with the cache key, + * or undefined if it's not set. + */ + getIntegrityForCacheKey(cacheKey) { + return this._cacheKeysToIntegrities.get(cacheKey); + } + /** + * This acts as a drop-in replacement for + * [`cache.match()`](https://developer.mozilla.org/en-US/docs/Web/API/Cache/match) + * with the following differences: + * + * - It knows what the name of the precache is, and only checks in that cache. + * - It allows you to pass in an "original" URL without versioning parameters, + * and it will automatically look up the correct cache key for the currently + * active revision of that URL. + * + * E.g., `matchPrecache('index.html')` will find the correct precached + * response for the currently active service worker, even if the actual cache + * key is `'/index.html?__WB_REVISION__=1234abcd'`. + * + * @param {string|Request} request The key (without revisioning parameters) + * to look up in the precache. + * @return {Promise} + */ + async matchPrecache(request) { + const url = request instanceof Request ? request.url : request; + const cacheKey = this.getCacheKeyForURL(url); + if (cacheKey) { + const cache = await self.caches.open(this.strategy.cacheName); + return cache.match(cacheKey); + } + return undefined; + } + /** + * Returns a function that looks up `url` in the precache (taking into + * account revision information), and returns the corresponding `Response`. + * + * @param {string} url The precached URL which will be used to lookup the + * `Response`. + * @return {workbox-routing~handlerCallback} + */ + createHandlerBoundToURL(url) { + const cacheKey = this.getCacheKeyForURL(url); + if (!cacheKey) { + throw new WorkboxError('non-precached-url', { + url + }); + } + return options => { + options.request = new Request(url); + options.params = Object.assign({ + cacheKey + }, options.params); + return this.strategy.handle(options); + }; + } + } + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + let precacheController; + /** + * @return {PrecacheController} + * @private + */ + const getOrCreatePrecacheController = () => { + if (!precacheController) { + precacheController = new PrecacheController(); + } + return precacheController; + }; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Removes any URL search parameters that should be ignored. + * + * @param {URL} urlObject The original URL. + * @param {Array} ignoreURLParametersMatching RegExps to test against + * each search parameter name. Matches mean that the search parameter should be + * ignored. + * @return {URL} The URL with any ignored search parameters removed. + * + * @private + * @memberof workbox-precaching + */ + function removeIgnoredSearchParams(urlObject, ignoreURLParametersMatching = []) { + // Convert the iterable into an array at the start of the loop to make sure + // deletion doesn't mess up iteration. + for (const paramName of [...urlObject.searchParams.keys()]) { + if (ignoreURLParametersMatching.some(regExp => regExp.test(paramName))) { + urlObject.searchParams.delete(paramName); + } + } + return urlObject; + } + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Generator function that yields possible variations on the original URL to + * check, one at a time. + * + * @param {string} url + * @param {Object} options + * + * @private + * @memberof workbox-precaching + */ + function* generateURLVariations(url, { + ignoreURLParametersMatching = [/^utm_/, /^fbclid$/], + directoryIndex = 'index.html', + cleanURLs = true, + urlManipulation + } = {}) { + const urlObject = new URL(url, location.href); + urlObject.hash = ''; + yield urlObject.href; + const urlWithoutIgnoredParams = removeIgnoredSearchParams(urlObject, ignoreURLParametersMatching); + yield urlWithoutIgnoredParams.href; + if (directoryIndex && urlWithoutIgnoredParams.pathname.endsWith('/')) { + const directoryURL = new URL(urlWithoutIgnoredParams.href); + directoryURL.pathname += directoryIndex; + yield directoryURL.href; + } + if (cleanURLs) { + const cleanURL = new URL(urlWithoutIgnoredParams.href); + cleanURL.pathname += '.html'; + yield cleanURL.href; + } + if (urlManipulation) { + const additionalURLs = urlManipulation({ + url: urlObject + }); + for (const urlToAttempt of additionalURLs) { + yield urlToAttempt.href; + } + } + } + + /* + Copyright 2020 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * A subclass of {@link workbox-routing.Route} that takes a + * {@link workbox-precaching.PrecacheController} + * instance and uses it to match incoming requests and handle fetching + * responses from the precache. + * + * @memberof workbox-precaching + * @extends workbox-routing.Route + */ + class PrecacheRoute extends Route { + /** + * @param {PrecacheController} precacheController A `PrecacheController` + * instance used to both match requests and respond to fetch events. + * @param {Object} [options] Options to control how requests are matched + * against the list of precached URLs. + * @param {string} [options.directoryIndex=index.html] The `directoryIndex` will + * check cache entries for a URLs ending with '/' to see if there is a hit when + * appending the `directoryIndex` value. + * @param {Array} [options.ignoreURLParametersMatching=[/^utm_/, /^fbclid$/]] An + * array of regex's to remove search params when looking for a cache match. + * @param {boolean} [options.cleanURLs=true] The `cleanURLs` option will + * check the cache for the URL with a `.html` added to the end of the end. + * @param {workbox-precaching~urlManipulation} [options.urlManipulation] + * This is a function that should take a URL and return an array of + * alternative URLs that should be checked for precache matches. + */ + constructor(precacheController, options) { + const match = ({ + request + }) => { + const urlsToCacheKeys = precacheController.getURLsToCacheKeys(); + for (const possibleURL of generateURLVariations(request.url, options)) { + const cacheKey = urlsToCacheKeys.get(possibleURL); + if (cacheKey) { + const integrity = precacheController.getIntegrityForCacheKey(cacheKey); + return { + cacheKey, + integrity + }; + } + } + { + logger.debug(`Precaching did not find a match for ` + getFriendlyURL(request.url)); + } + return; + }; + super(match, precacheController.strategy); + } + } + + /* + Copyright 2019 Google LLC + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Add a `fetch` listener to the service worker that will + * respond to + * [network requests]{@link https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers#Custom_responses_to_requests} + * with precached assets. + * + * Requests for assets that aren't precached, the `FetchEvent` will not be + * responded to, allowing the event to fall through to other `fetch` event + * listeners. + * + * @param {Object} [options] See the {@link workbox-precaching.PrecacheRoute} + * options. + * + * @memberof workbox-precaching + */ + function addRoute(options) { + const precacheController = getOrCreatePrecacheController(); + const precacheRoute = new PrecacheRoute(precacheController, options); + registerRoute(precacheRoute); + } + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Adds items to the precache list, removing any duplicates and + * stores the files in the + * {@link workbox-core.cacheNames|"precache cache"} when the service + * worker installs. + * + * This method can be called multiple times. + * + * Please note: This method **will not** serve any of the cached files for you. + * It only precaches files. To respond to a network request you call + * {@link workbox-precaching.addRoute}. + * + * If you have a single array of files to precache, you can just call + * {@link workbox-precaching.precacheAndRoute}. + * + * @param {Array} [entries=[]] Array of entries to precache. + * + * @memberof workbox-precaching + */ + function precache(entries) { + const precacheController = getOrCreatePrecacheController(); + precacheController.precache(entries); + } + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * This method will add entries to the precache list and add a route to + * respond to fetch events. + * + * This is a convenience method that will call + * {@link workbox-precaching.precache} and + * {@link workbox-precaching.addRoute} in a single call. + * + * @param {Array} entries Array of entries to precache. + * @param {Object} [options] See the + * {@link workbox-precaching.PrecacheRoute} options. + * + * @memberof workbox-precaching + */ + function precacheAndRoute(entries, options) { + precache(entries); + addRoute(options); + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + const SUBSTRING_TO_FIND = '-precache-'; + /** + * Cleans up incompatible precaches that were created by older versions of + * Workbox, by a service worker registered under the current scope. + * + * This is meant to be called as part of the `activate` event. + * + * This should be safe to use as long as you don't include `substringToFind` + * (defaulting to `-precache-`) in your non-precache cache names. + * + * @param {string} currentPrecacheName The cache name currently in use for + * precaching. This cache won't be deleted. + * @param {string} [substringToFind='-precache-'] Cache names which include this + * substring will be deleted (excluding `currentPrecacheName`). + * @return {Array} A list of all the cache names that were deleted. + * + * @private + * @memberof workbox-precaching + */ + const deleteOutdatedCaches = async (currentPrecacheName, substringToFind = SUBSTRING_TO_FIND) => { + const cacheNames = await self.caches.keys(); + const cacheNamesToDelete = cacheNames.filter(cacheName => { + return cacheName.includes(substringToFind) && cacheName.includes(self.registration.scope) && cacheName !== currentPrecacheName; + }); + await Promise.all(cacheNamesToDelete.map(cacheName => self.caches.delete(cacheName))); + return cacheNamesToDelete; + }; + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Adds an `activate` event listener which will clean up incompatible + * precaches that were created by older versions of Workbox. + * + * @memberof workbox-precaching + */ + function cleanupOutdatedCaches() { + // See https://github.com/Microsoft/TypeScript/issues/28357#issuecomment-436484705 + self.addEventListener('activate', event => { + const cacheName = cacheNames.getPrecacheName(); + event.waitUntil(deleteOutdatedCaches(cacheName).then(cachesDeleted => { + { + if (cachesDeleted.length > 0) { + logger.log(`The following out-of-date precaches were cleaned up ` + `automatically:`, cachesDeleted); + } + } + })); + }); + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * NavigationRoute makes it easy to create a + * {@link workbox-routing.Route} that matches for browser + * [navigation requests]{@link https://developers.google.com/web/fundamentals/primers/service-workers/high-performance-loading#first_what_are_navigation_requests}. + * + * It will only match incoming Requests whose + * {@link https://fetch.spec.whatwg.org/#concept-request-mode|mode} + * is set to `navigate`. + * + * You can optionally only apply this route to a subset of navigation requests + * by using one or both of the `denylist` and `allowlist` parameters. + * + * @memberof workbox-routing + * @extends workbox-routing.Route + */ + class NavigationRoute extends Route { + /** + * If both `denylist` and `allowlist` are provided, the `denylist` will + * take precedence and the request will not match this route. + * + * The regular expressions in `allowlist` and `denylist` + * are matched against the concatenated + * [`pathname`]{@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLHyperlinkElementUtils/pathname} + * and [`search`]{@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLHyperlinkElementUtils/search} + * portions of the requested URL. + * + * *Note*: These RegExps may be evaluated against every destination URL during + * a navigation. Avoid using + * [complex RegExps](https://github.com/GoogleChrome/workbox/issues/3077), + * or else your users may see delays when navigating your site. + * + * @param {workbox-routing~handlerCallback} handler A callback + * function that returns a Promise resulting in a Response. + * @param {Object} options + * @param {Array} [options.denylist] If any of these patterns match, + * the route will not handle the request (even if a allowlist RegExp matches). + * @param {Array} [options.allowlist=[/./]] If any of these patterns + * match the URL's pathname and search parameter, the route will handle the + * request (assuming the denylist doesn't match). + */ + constructor(handler, { + allowlist = [/./], + denylist = [] + } = {}) { + { + finalAssertExports.isArrayOfClass(allowlist, RegExp, { + moduleName: 'workbox-routing', + className: 'NavigationRoute', + funcName: 'constructor', + paramName: 'options.allowlist' + }); + finalAssertExports.isArrayOfClass(denylist, RegExp, { + moduleName: 'workbox-routing', + className: 'NavigationRoute', + funcName: 'constructor', + paramName: 'options.denylist' + }); + } + super(options => this._match(options), handler); + this._allowlist = allowlist; + this._denylist = denylist; + } + /** + * Routes match handler. + * + * @param {Object} options + * @param {URL} options.url + * @param {Request} options.request + * @return {boolean} + * + * @private + */ + _match({ + url, + request + }) { + if (request && request.mode !== 'navigate') { + return false; + } + const pathnameAndSearch = url.pathname + url.search; + for (const regExp of this._denylist) { + if (regExp.test(pathnameAndSearch)) { + { + logger.log(`The navigation route ${pathnameAndSearch} is not ` + `being used, since the URL matches this denylist pattern: ` + `${regExp.toString()}`); + } + return false; + } + } + if (this._allowlist.some(regExp => regExp.test(pathnameAndSearch))) { + { + logger.debug(`The navigation route ${pathnameAndSearch} ` + `is being used.`); + } + return true; + } + { + logger.log(`The navigation route ${pathnameAndSearch} is not ` + `being used, since the URL being navigated to doesn't ` + `match the allowlist.`); + } + return false; + } + } + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Helper function that calls + * {@link PrecacheController#createHandlerBoundToURL} on the default + * {@link PrecacheController} instance. + * + * If you are creating your own {@link PrecacheController}, then call the + * {@link PrecacheController#createHandlerBoundToURL} on that instance, + * instead of using this function. + * + * @param {string} url The precached URL which will be used to lookup the + * `Response`. + * @param {boolean} [fallbackToNetwork=true] Whether to attempt to get the + * response from the network if there's a precache miss. + * @return {workbox-routing~handlerCallback} + * + * @memberof workbox-precaching + */ + function createHandlerBoundToURL(url) { + const precacheController = getOrCreatePrecacheController(); + return precacheController.createHandlerBoundToURL(url); + } + + exports.CacheableResponsePlugin = CacheableResponsePlugin; + exports.ExpirationPlugin = ExpirationPlugin; + exports.NavigationRoute = NavigationRoute; + exports.NetworkFirst = NetworkFirst; + exports.cleanupOutdatedCaches = cleanupOutdatedCaches; + exports.clientsClaim = clientsClaim; + exports.createHandlerBoundToURL = createHandlerBoundToURL; + exports.precacheAndRoute = precacheAndRoute; + exports.registerRoute = registerRoute; + +})); diff --git a/mengyamonitor-frontend/index.html b/mengyamonitor-frontend/index.html index 3f0b36e..0cc17c1 100644 --- a/mengyamonitor-frontend/index.html +++ b/mengyamonitor-frontend/index.html @@ -2,11 +2,12 @@ - - + + + diff --git a/mengyamonitor-frontend/package-lock.json b/mengyamonitor-frontend/package-lock.json index 7088438..c7c6b22 100644 --- a/mengyamonitor-frontend/package-lock.json +++ b/mengyamonitor-frontend/package-lock.json @@ -8,9 +8,6 @@ "name": "mengyamonitor-frontend", "version": "0.0.0", "dependencies": { - "@dnd-kit/core": "^6.3.1", - "@dnd-kit/sortable": "^10.0.0", - "@dnd-kit/utilities": "^3.2.2", "react": "^19.2.0", "react-dom": "^19.2.0" }, @@ -1586,59 +1583,6 @@ "node": ">=6.9.0" } }, - "node_modules/@dnd-kit/accessibility": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", - "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "peerDependencies": { - "react": ">=16.8.0" - } - }, - "node_modules/@dnd-kit/core": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", - "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", - "license": "MIT", - "dependencies": { - "@dnd-kit/accessibility": "^3.1.1", - "@dnd-kit/utilities": "^3.2.2", - "tslib": "^2.0.0" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@dnd-kit/sortable": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", - "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", - "license": "MIT", - "dependencies": { - "@dnd-kit/utilities": "^3.2.2", - "tslib": "^2.0.0" - }, - "peerDependencies": { - "@dnd-kit/core": "^6.3.0", - "react": ">=16.8.0" - } - }, - "node_modules/@dnd-kit/utilities": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", - "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "peerDependencies": { - "react": ">=16.8.0" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -6606,12 +6550,6 @@ "typescript": ">=4.8.4" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/mengyamonitor-frontend/package.json b/mengyamonitor-frontend/package.json index 8903a70..66bb334 100644 --- a/mengyamonitor-frontend/package.json +++ b/mengyamonitor-frontend/package.json @@ -10,9 +10,6 @@ "preview": "vite preview" }, "dependencies": { - "@dnd-kit/core": "^6.3.1", - "@dnd-kit/sortable": "^10.0.0", - "@dnd-kit/utilities": "^3.2.2", "react": "^19.2.0", "react-dom": "^19.2.0" }, diff --git a/mengyamonitor-frontend/public/favicon.ico b/mengyamonitor-frontend/public/favicon.ico new file mode 100644 index 0000000..67c32ba Binary files /dev/null and b/mengyamonitor-frontend/public/favicon.ico differ diff --git a/mengyamonitor-frontend/public/logo.png b/mengyamonitor-frontend/public/logo.png index eec06de..62856ba 100644 Binary files a/mengyamonitor-frontend/public/logo.png and b/mengyamonitor-frontend/public/logo.png differ diff --git a/mengyamonitor-frontend/public/logo.svg b/mengyamonitor-frontend/public/logo.svg deleted file mode 100644 index 70974ae..0000000 --- a/mengyamonitor-frontend/public/logo.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/mengyamonitor-frontend/src/App.css b/mengyamonitor-frontend/src/App.css index 7522e95..f29a172 100644 --- a/mengyamonitor-frontend/src/App.css +++ b/mengyamonitor-frontend/src/App.css @@ -1,8 +1,9 @@ .app { + position: relative; width: 100%; max-width: 100%; margin: 0 auto; - padding: 1rem; + padding: 0.65rem 0.75rem 0.85rem; min-height: 100vh; } @@ -10,12 +11,14 @@ display: flex; justify-content: space-between; align-items: center; - margin-bottom: 1rem; - padding: 0.5rem 1rem; + margin-bottom: 0.65rem; + padding: 0.4rem 0.85rem; background: var(--surface-color); - border-radius: var(--radius-md); + backdrop-filter: var(--backdrop-blur); + -webkit-backdrop-filter: var(--backdrop-blur); + border-radius: var(--radius-glass); box-shadow: var(--shadow-sm); - border: 1px solid var(--border-color); + border: 1px solid var(--glass-border); transition: all 0.3s ease; } @@ -27,27 +30,29 @@ .app-header h1 { margin: 0; - font-size: 1.5rem; + font-size: 1.2rem; color: var(--text-main); display: flex; align-items: center; - gap: 0.75rem; - font-weight: 600; - transform: translateY(-3px); + gap: 0.55rem; + font-weight: 700; + transform: translateY(-1px); + letter-spacing: -0.02em; } .app-logo { - width: 36px; - height: 36px; - border-radius: 6px; + width: 32px; + height: 32px; + border-radius: var(--radius-md); display: inline-block; + object-fit: contain; } .btn-icon { - background: var(--surface-color); + background: rgba(255, 255, 255, 0.35); color: var(--text-main); - border: 1px solid var(--border-color); - padding: 0.5rem; + border: 1px solid var(--glass-border); + padding: 0.45rem; width: 32px; height: 32px; border-radius: var(--radius-md); @@ -183,18 +188,20 @@ .server-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); - gap: 1.5rem; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 0.75rem; } .empty-state { grid-column: 1 / -1; text-align: center; - padding: 4rem 2rem; + padding: 2rem 1.25rem; background: var(--surface-color); - border-radius: var(--radius-lg); + backdrop-filter: var(--backdrop-blur); + -webkit-backdrop-filter: var(--backdrop-blur); + border-radius: var(--radius-glass); box-shadow: var(--shadow-sm); - border: 1px dashed var(--border-color); + border: 1px dashed var(--glass-border); } .empty-state p { @@ -208,6 +215,95 @@ font-size: 0.875rem; } +.logo-hit { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + margin: 0; + border: none; + background: transparent; + cursor: pointer; + border-radius: var(--radius-md); +} + +.logo-hit:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; +} + +.token-gate-overlay { + position: fixed; + inset: 0; + z-index: 1100; + background: rgba(10, 12, 22, 0.75); + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.token-gate-dialog { + width: min(400px, 100%); + background: var(--surface-color); + backdrop-filter: var(--backdrop-blur); + -webkit-backdrop-filter: var(--backdrop-blur); + border: 1px solid var(--glass-border); + border-radius: var(--radius-glass); + padding: 1.1rem 1.25rem; + box-shadow: var(--shadow-md); +} + +.token-gate-dialog h3 { + margin: 0 0 0.5rem; + font-size: 1.125rem; + color: var(--text-main); +} + +.token-gate-hint { + margin: 0 0 1rem; + font-size: 0.8125rem; + color: var(--text-secondary); + line-height: 1.45; +} + +.token-gate-input { + width: 100%; + box-sizing: border-box; + padding: 0.65rem 0.75rem; + border-radius: var(--radius-md); + border: 1px solid var(--border-color); + background: var(--bg-color); + color: var(--text-main); + margin-bottom: 0.5rem; +} + +.token-gate-error { + color: #f87171; + font-size: 0.8125rem; + margin: 0 0 0.75rem; +} + +.token-gate-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.btn-cancel { + background: var(--surface-color); + color: var(--text-main); + border: 1px solid var(--border-color); + padding: 0.65rem 1.25rem; + border-radius: var(--radius-md); + font-size: 0.875rem; + cursor: pointer; +} + +.btn-cancel:hover { + border-color: var(--primary-color); +} + @media (max-width: 768px) { .app { padding: 1rem; diff --git a/mengyamonitor-frontend/src/App.tsx b/mengyamonitor-frontend/src/App.tsx index 151abe4..cb8a2c0 100644 --- a/mengyamonitor-frontend/src/App.tsx +++ b/mengyamonitor-frontend/src/App.tsx @@ -1,247 +1,210 @@ -import { useState, useEffect } from 'react'; -import type { ServerConfig } from './types'; -import { loadServers, saveServers, removeServer, exportServersToClipboard, importServersFromClipboard } from './utils/storage'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { useServerMonitor } from './hooks/useServerMonitor'; +import { usePublicServers } from './hooks/usePublicServers'; import { ServerCard } from './components/ServerCard/ServerCard'; import { ServerDetail } from './components/ServerDetail/ServerDetail'; +import { AdminPanel } from './components/AdminPanel/AdminPanel'; +import { RandomBackdrop } from './components/RandomBackdrop/RandomBackdrop'; import { - DndContext, - closestCenter, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, -} from '@dnd-kit/core'; -import type { DragEndEvent } from '@dnd-kit/core'; -import { - arrayMove, - SortableContext, - sortableKeyboardCoordinates, - rectSortingStrategy, -} from '@dnd-kit/sortable'; + verifyAdminToken, + ADMIN_TOKEN_STORAGE, +} from './api/central'; import './App.css'; -const formatServerUrl = (url: string) => { - let formatted = url.trim(); - - // Fix common typo .op -> .top based on user requirement - if (formatted.endsWith('.op')) { - formatted = formatted.slice(0, -3) + '.top'; - } - - if (formatted && !/^https?:\/\//i.test(formatted)) { - return `http://${formatted}`; - } - return formatted; -}; - function App() { - const [servers, setServers] = useState([]); - const [showAddForm, setShowAddForm] = useState(false); + const { servers, loading, error, reload } = usePublicServers(30000); const [selectedServerId, setSelectedServerId] = useState(null); - const [newServerForm, setNewServerForm] = useState({ name: '', url: '' }); const [showHeader, setShowHeader] = useState(true); - const statuses = useServerMonitor(servers, 2000); + const [showTokenGate, setShowTokenGate] = useState(false); + const [tokenInput, setTokenInput] = useState(''); + const [tokenError, setTokenError] = useState(null); + const [adminOpen, setAdminOpen] = useState(false); - const sensors = useSensors( - useSensor(PointerSensor), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }) - ); + const logoClickCount = useRef(0); + const logoClickReset = useRef | null>(null); - useEffect(() => { - const loaded = loadServers(); - setServers(loaded); + const statuses = useServerMonitor(); + + const handleLogoClick = useCallback(() => { + if (logoClickReset.current) { + clearTimeout(logoClickReset.current); + } + logoClickCount.current += 1; + if (logoClickCount.current >= 5) { + logoClickCount.current = 0; + const saved = sessionStorage.getItem(ADMIN_TOKEN_STORAGE); + if (saved) { + setAdminOpen(true); + } else { + setShowTokenGate(true); + setTokenInput(''); + setTokenError(null); + } + return; + } + logoClickReset.current = setTimeout(() => { + logoClickCount.current = 0; + }, 2000); }, []); - const handleUrlBlur = () => { - const formatted = formatServerUrl(newServerForm.url); - if (formatted !== newServerForm.url) { - setNewServerForm(prev => ({ ...prev, url: formatted })); - } - }; - - const handleAddServer = () => { - if (!newServerForm.name || !newServerForm.url) { - alert('请填写服务器名称和地址'); + const submitToken = async () => { + const ok = await verifyAdminToken(tokenInput.trim()); + if (!ok) { + setTokenError('口令错误'); return; } + sessionStorage.setItem(ADMIN_TOKEN_STORAGE, tokenInput.trim()); + setShowTokenGate(false); + setTokenError(null); + setAdminOpen(true); + }; - const formattedUrl = formatServerUrl(newServerForm.url); + const closeAdmin = () => { + setAdminOpen(false); + }; - const newServer: ServerConfig = { - id: Date.now().toString(), - name: newServerForm.name, - url: formattedUrl, - enabled: true, + const invalidateAdminSession = () => { + sessionStorage.removeItem(ADMIN_TOKEN_STORAGE); + setAdminOpen(false); + alert('管理凭证已失效,请重新验证'); + }; + + useEffect(() => { + return () => { + if (logoClickReset.current) clearTimeout(logoClickReset.current); }; - - const updated = [...servers, newServer]; - setServers(updated); - saveServers(updated); - setNewServerForm({ name: '', url: '' }); - setShowAddForm(false); - }; - - const handleRemoveServer = (serverId: string) => { - if (confirm('确定要移除这个服务器吗?')) { - const updated = servers.filter(s => s.id !== serverId); - setServers(updated); - removeServer(serverId); - } - }; - - const handleShowDetail = (serverId: string) => { - setSelectedServerId(serverId); - }; - - const handleExportServers = async () => { - try { - await exportServersToClipboard(); - alert('服务器配置已复制到剪贴板!'); - } catch (error) { - alert(error instanceof Error ? error.message : '导出失败'); - } - }; - - const handleImportServers = async () => { - if (!confirm('导入服务器配置将添加到现有服务器列表中,是否继续?')) { - return; - } - - try { - const importedServers = await importServersFromClipboard(); - const updated = [...servers, ...importedServers]; - setServers(updated); - saveServers(updated); - alert(`成功导入 ${importedServers.length} 个服务器配置!`); - } catch (error) { - alert(error instanceof Error ? error.message : '导入失败'); - } - }; - - const handleDragEnd = (event: DragEndEvent) => { - const { active, over } = event; - - if (over && active.id !== over.id) { - setServers((items) => { - const oldIndex = items.findIndex((item) => item.id === active.id); - const newIndex = items.findIndex((item) => item.id === over.id); - const newOrder = arrayMove(items, oldIndex, newIndex); - saveServers(newOrder); - return newOrder; - }); - } - }; + }, []); const selectedStatus = selectedServerId ? statuses[selectedServerId] : null; - const selectedServer = servers.find(s => s.id === selectedServerId); + const selectedServer = servers.find((s) => s.id === selectedServerId); + + const adminToken = sessionStorage.getItem(ADMIN_TOKEN_STORAGE) || ''; return (
+ + {showTokenGate && ( +
+
+

管理验证

+

请输入管理口令(内网默认与中心服务 ADMIN_TOKEN 一致)。

+ { + setTokenInput(e.target.value); + setTokenError(null); + }} + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') void submitToken(); + }} + /> + {tokenError &&

{tokenError}

} +
+ + +
+
+
+ )} + + {adminOpen && adminToken && ( + void reload()} + /> + )} + {!showHeader && ( )} - + {showHeader && (

- 萌芽监控面板 + 萌芽监控面板

- - - - -
-
- )} - - {showAddForm && ( -
- setNewServerForm({ ...newServerForm, name: e.target.value })} - /> - setNewServerForm({ ...newServerForm, url: e.target.value })} - onBlur={handleUrlBlur} - /> - -
+
+ )}
- {servers.length === 0 ? ( + {loading ? (
-

还没有添加任何服务器

-

点击右上角的"添加服务器"按钮开始使用

+

正在从中心服务加载节点列表…

+
+ ) : error ? ( +
+

无法加载监控列表

+

{error}

+

请确认 mengyamonitor-backend-server 已启动,且 VITE_CENTRAL_API_URL 或开发代理配置正确。

+
+ ) : servers.length === 0 ? ( +
+

暂无已启用的监控节点

+

请联系管理员在后台添加采集端地址。

) : ( - - s.id)} strategy={rectSortingStrategy}> - {servers.map((server) => { - const status = statuses[server.id]; - // Calculate storage usage (max of all mounts) - const storageUsage = status?.metrics?.storage?.reduce((max, s) => Math.max(max, s.usedPercent), 0) || 0; - - return ( - - ); - })} - - + servers.map((server) => { + const status = statuses[server.id]; + const storageUsage = + status?.metrics?.storage?.reduce((max, s) => Math.max(max, s.usedPercent), 0) || 0; + return ( + setSelectedServerId(server.id)} + /> + ); + }) )}
diff --git a/mengyamonitor-frontend/src/api/central.ts b/mengyamonitor-frontend/src/api/central.ts new file mode 100644 index 0000000..d90a11f --- /dev/null +++ b/mengyamonitor-frontend/src/api/central.ts @@ -0,0 +1,109 @@ +/** Central dashboard API base (no trailing slash). Dev default uses Vite proxy. */ +export const centralApiBase = (): string => { + const v = import.meta.env.VITE_CENTRAL_API_URL as string | undefined; + return (v && v.trim()) || '/central-api'; +}; + +export async function fetchPublicServers(): Promise< + { id: string; name: string; url: string; enabled: boolean }[] +> { + const base = centralApiBase(); + const res = await fetch(`${base}/api/servers`); + if (!res.ok) { + throw new Error(`无法加载服务器列表 (${res.status})`); + } + const body = await res.json(); + const data = body.data; + if (!Array.isArray(data)) { + throw new Error('无效的服务器列表响应'); + } + return data; +} + +export async function verifyAdminToken(token: string): Promise { + const base = centralApiBase(); + const res = await fetch(`${base}/api/admin/auth`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + return res.ok; +} + +const adminHeaders = (token: string, json = false): HeadersInit => { + const h: Record = { 'X-Admin-Token': token }; + if (json) h['Content-Type'] = 'application/json'; + return h; +}; + +export async function adminListServers(token: string) { + const base = centralApiBase(); + const res = await fetch(`${base}/api/admin/servers`, { headers: adminHeaders(token) }); + if (res.status === 401) throw new Error('UNAUTHORIZED'); + if (!res.ok) throw new Error(`加载失败 (${res.status})`); + const body = await res.json(); + return body.data as { + id: string; + name: string; + url: string; + enabled: boolean; + sortOrder: number; + agentKey?: string; + }[]; +} + +export async function adminCreateServer( + token: string, + payload: { name: string; url?: string; enabled: boolean } +) { + const base = centralApiBase(); + const res = await fetch(`${base}/api/admin/servers`, { + method: 'POST', + headers: adminHeaders(token, true), + body: JSON.stringify(payload), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error((err as { error?: string }).error || `创建失败 (${res.status})`); + } + return (await res.json()).data; +} + +export async function adminUpdateServer( + token: string, + id: string, + payload: { name: string; url?: string; enabled: boolean; agentKey?: string } +) { + const base = centralApiBase(); + const res = await fetch(`${base}/api/admin/servers/${encodeURIComponent(id)}`, { + method: 'PUT', + headers: adminHeaders(token, true), + body: JSON.stringify(payload), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error((err as { error?: string }).error || `更新失败 (${res.status})`); + } + return (await res.json()).data; +} + +export async function adminDeleteServer(token: string, id: string) { + const base = centralApiBase(); + const res = await fetch(`${base}/api/admin/servers/${encodeURIComponent(id)}`, { + method: 'DELETE', + headers: adminHeaders(token, false), + }); + if (!res.ok) throw new Error(`删除失败 (${res.status})`); +} + +export async function adminReorderServers(token: string, ids: string[]) { + const base = centralApiBase(); + const res = await fetch(`${base}/api/admin/servers/reorder`, { + method: 'PUT', + headers: adminHeaders(token, true), + body: JSON.stringify({ ids }), + }); + if (!res.ok) throw new Error(`排序失败 (${res.status})`); +} + +export const ADMIN_TOKEN_STORAGE = 'mengya_monitor_admin_token'; diff --git a/mengyamonitor-frontend/src/api/monitor.ts b/mengyamonitor-frontend/src/api/monitor.ts deleted file mode 100644 index a5b22e4..0000000 --- a/mengyamonitor-frontend/src/api/monitor.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { ServerMetrics } from '../types'; - -export const fetchServerMetrics = async (serverUrl: string): Promise => { - // 测量客户端到服务器的延迟(使用健康检查端点) - const clientToServerLatency = await measureLatency(`${serverUrl}/api/health`); - - // 并行请求各个端点 - const endpoints = [ - '/api/metrics/cpu', - '/api/metrics/memory', - '/api/metrics/storage', - '/api/metrics/gpu', - '/api/metrics/network', - '/api/metrics/system', - '/api/metrics/docker', - '/api/metrics/latency', - ]; - - const results = await Promise.all( - endpoints.map(endpoint => - fetch(`${serverUrl}${endpoint}`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }) - .then(res => { - if (!res.ok) throw new Error(`Failed to fetch ${endpoint}`); - return res.json(); - }) - .catch(err => { - console.error(`Error fetching ${endpoint}:`, err); - return null; - }) - ) - ); - - // 合并所有结果 - const [cpuRes, memRes, storageRes, gpuRes, networkRes, systemRes, dockerRes, latencyRes] = results; - - const system = systemRes?.data || {}; - const docker = dockerRes?.data || {}; - const latency = latencyRes?.data || {}; - - // 将 docker 数据合并到 system.dockerStats - return { - hostname: system.hostname || 'Unknown', - timestamp: new Date().toISOString(), - cpu: cpuRes?.data || {}, - memory: memRes?.data || {}, - storage: storageRes?.data || [], - gpu: gpuRes?.data || [], - network: networkRes?.data || [], - system: { - ...system, - dockerStats: docker - }, - os: system.os || { kernel: '', distro: '', architecture: '' }, - uptimeSeconds: system.uptimeSeconds || 0, - latency: { - clientToServer: clientToServerLatency, - external: latency.external || {}, - }, - }; -}; - -// 测量延迟 -async function measureLatency(url: string): Promise { - try { - const startTime = performance.now(); - const response = await fetch(url, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); - const endTime = performance.now(); - - if (response.ok) { - return Math.round(endTime - startTime); - } - return -1; // 表示失败 - } catch { - return -1; // 表示超时或失败 - } -} - -export const checkServerHealth = async (serverUrl: string): Promise => { - try { - const url = `${serverUrl}/api/health`; - const response = await fetch(url, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); - return response.ok; - } catch { - return false; - } -}; diff --git a/mengyamonitor-frontend/src/components/AdminPanel/AdminPanel.css b/mengyamonitor-frontend/src/components/AdminPanel/AdminPanel.css new file mode 100644 index 0000000..06bee05 --- /dev/null +++ b/mengyamonitor-frontend/src/components/AdminPanel/AdminPanel.css @@ -0,0 +1,202 @@ +.admin-overlay { + position: fixed; + inset: 0; + background: rgba(10, 12, 22, 0.72); + z-index: 1000; + display: flex; + align-items: flex-start; + justify-content: center; + padding: 2rem 1rem; + overflow: auto; +} + +.admin-panel { + width: min(1100px, 100%); + background: var(--surface-color); + backdrop-filter: var(--backdrop-blur); + -webkit-backdrop-filter: var(--backdrop-blur); + border: 1px solid var(--glass-border); + border-radius: var(--radius-glass); + box-shadow: var(--shadow-lg); + padding: 1rem 1.15rem 1.25rem; +} + +.admin-panel-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; +} + +.admin-panel-header h2 { + margin: 0; + font-size: 1.25rem; + color: var(--text-main); +} + +.admin-close { + background: transparent; + border: none; + color: var(--text-secondary); + font-size: 1.75rem; + line-height: 1; + cursor: pointer; + padding: 0.25rem 0.5rem; + border-radius: var(--radius-md); +} + +.admin-close:hover { + background: var(--bg-color); + color: var(--text-main); +} + +.admin-hint { + margin: 0 0 1rem; + font-size: 0.875rem; + color: var(--text-secondary); + line-height: 1.5; +} + +.admin-loading { + color: var(--text-secondary); +} + +.admin-error { + color: #f87171; + margin-bottom: 0.75rem; +} + +.admin-table-wrap { + overflow-x: auto; +} + +.admin-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} + +.admin-table th, +.admin-table td { + text-align: left; + padding: 0.5rem 0.6rem; + border-bottom: 1px solid var(--border-color); + vertical-align: middle; +} + +.admin-table th { + color: var(--text-secondary); + font-weight: 600; +} + +.admin-table code { + font-size: 0.8rem; + word-break: break-all; +} + +.admin-table input[type='text'] { + width: 100%; + max-width: 280px; + padding: 0.35rem 0.5rem; + border-radius: var(--radius-md); + border: 1px solid var(--border-color); + background: var(--bg-color); + color: var(--text-main); +} + +.admin-url-input { + max-width: 360px !important; +} + +.admin-order-btns { + display: flex; + gap: 0.25rem; +} + +.admin-order-btns button { + padding: 0.2rem 0.45rem; + font-size: 0.75rem; + cursor: pointer; + border-radius: var(--radius-sm); + border: 1px solid var(--border-color); + background: var(--bg-color); + color: var(--text-main); +} + +.admin-order-btns button:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.admin-actions { + white-space: nowrap; +} + +.admin-actions .admin-btn + .admin-btn { + margin-left: 0.35rem; +} + +.admin-btn { + padding: 0.35rem 0.65rem; + font-size: 0.8rem; + border-radius: var(--radius-md); + border: 1px solid var(--border-color); + background: var(--surface-color); + color: var(--text-main); + cursor: pointer; +} + +.admin-btn:hover { + border-color: var(--primary-color); +} + +.admin-btn.danger { + border-color: #b91c1c; + color: #fecaca; +} + +.admin-btn.danger:hover { + background: rgba(185, 28, 28, 0.2); +} + +.admin-server-id-cell, +.admin-agent-key-cell { + vertical-align: top; + max-width: 22rem; +} + +.admin-agent-key-cell .admin-key { + display: block; + margin-bottom: 0.35rem; +} + +.admin-server-id { + display: block; + font-size: 0.68rem; + line-height: 1.35; + word-break: break-all; + margin-bottom: 0.35rem; + color: var(--text-main); +} + +.admin-btn-ghost { + padding: 0.2rem 0.45rem; + font-size: 0.72rem; +} + +.admin-key { + font-size: 0.75rem; + word-break: break-all; + cursor: help; +} + +.admin-muted { + color: var(--text-secondary); + font-size: 0.8rem; +} + +.admin-empty { + text-align: center; + color: var(--text-secondary); + padding: 1.5rem !important; +} diff --git a/mengyamonitor-frontend/src/components/AdminPanel/AdminPanel.tsx b/mengyamonitor-frontend/src/components/AdminPanel/AdminPanel.tsx new file mode 100644 index 0000000..4766c5b --- /dev/null +++ b/mengyamonitor-frontend/src/components/AdminPanel/AdminPanel.tsx @@ -0,0 +1,279 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + adminListServers, + adminUpdateServer, + adminDeleteServer, + adminReorderServers, +} from '../../api/central'; +import './AdminPanel.css'; + +type Row = { id: string; name: string; url: string; enabled: boolean; sortOrder: number; agentKey?: string }; + +const formatServerUrl = (url: string) => { + let formatted = url.trim(); + if (formatted.endsWith('.op')) { + formatted = formatted.slice(0, -3) + '.top'; + } + if (formatted && !/^https?:\/\//i.test(formatted)) { + return `http://${formatted}`; + } + return formatted; +}; + +export const AdminPanel = ({ + token, + onClose, + onSessionInvalid, + onSaved, +}: { + token: string; + onClose: () => void; + onSessionInvalid: () => void; + onSaved?: () => void; +}) => { + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + const [editId, setEditId] = useState(null); + const [editForm, setEditForm] = useState({ name: '', url: '', enabled: true }); + + const load = useCallback(async () => { + setErr(null); + try { + const list = await adminListServers(token); + setRows(list); + } catch (e) { + if (e instanceof Error && e.message === 'UNAUTHORIZED') { + onSessionInvalid(); + return; + } + setErr(e instanceof Error ? e.message : '加载失败'); + } finally { + setLoading(false); + } + }, [token, onSessionInvalid]); + + useEffect(() => { + void load(); + }, [load]); + + const startEdit = (r: Row) => { + setEditId(r.id); + setEditForm({ name: r.name, url: r.url, enabled: r.enabled }); + }; + + const saveEdit = async () => { + if (!editId) return; + try { + const u = editForm.url.trim(); + await adminUpdateServer(token, editId, { + name: editForm.name.trim(), + ...(u ? { url: formatServerUrl(u) } : { url: '' }), + enabled: editForm.enabled, + }); + setEditId(null); + await load(); + onSaved?.(); + } catch (e) { + alert(e instanceof Error ? e.message : '保存失败'); + } + }; + + const remove = async (id: string) => { + if (!confirm('确定删除该监控节点?')) return; + try { + await adminDeleteServer(token, id); + await load(); + onSaved?.(); + } catch (e) { + alert(e instanceof Error ? e.message : '删除失败'); + } + }; + + const copyServerId = useCallback((id: string) => { + void navigator.clipboard.writeText(id).catch(() => { + window.prompt('请手动复制(环境变量 SERVER_ID)', id); + }); + }, []); + + const copyAgentKey = useCallback((key: string) => { + void navigator.clipboard.writeText(key).catch(() => { + window.prompt('请手动复制(环境变量 AGENT_KEY)', key); + }); + }, []); + + const move = async (index: number, dir: -1 | 1) => { + const j = index + dir; + if (j < 0 || j >= rows.length) return; + const next = [...rows]; + [next[index], next[j]] = [next[j], next[index]]; + const ids = next.map((r) => r.id); + setRows(next); + try { + await adminReorderServers(token, ids); + await load(); + onSaved?.(); + } catch (e) { + alert(e instanceof Error ? e.message : '排序失败'); + void load(); + } + }; + + return ( +
+
+
+

监控管理后台

+ +
+

+ 节点由采集端 gRPC 上报后出现在列表中。环境变量 CENTRAL_GRPC、SERVER_ID 须为下表「节点 ID」列的完整 UUID + (勿把「名称」当成 SERVER_ID)、AGENT_KEY 与「节点密钥」一致。看板经 WebSocket + 拉取中心内存快照;「备注链接」可选,可在行内编辑。 +

+ + {loading ? ( +

加载中…

+ ) : ( + <> + {err &&

{err}

} + +
+ + + + + + + + + + + + + {rows.length === 0 ? ( + + + + ) : ( + rows.map((r, i) => ( + + + + + + + + + + )) + )} + +
顺序名称节点 ID(SERVER_ID)备注链接节点密钥 (gRPC)启用 +
+ 暂无节点;请确认采集端已配置并连接中心,成功上报后将显示在此。 +
+
+ + +
+
+ {editId === r.id ? ( + setEditForm({ ...editForm, name: e.target.value })} + /> + ) : ( + r.name + )} + + + {r.id} + + + + {editId === r.id ? ( + setEditForm({ ...editForm, url: e.target.value })} + /> + ) : r.url ? ( + {r.url} + ) : ( + + )} + + {r.agentKey ? ( + <> + + {r.agentKey.slice(0, 8)}… + + + + ) : ( + + )} + + {editId === r.id ? ( + setEditForm({ ...editForm, enabled: e.target.checked })} + /> + ) : ( + {r.enabled ? '是' : '否'} + )} + + {editId === r.id ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+ + )} +
+
+ ); +}; diff --git a/mengyamonitor-frontend/src/components/Common/CircularProgress.css b/mengyamonitor-frontend/src/components/Common/CircularProgress.css deleted file mode 100644 index 95634c9..0000000 --- a/mengyamonitor-frontend/src/components/Common/CircularProgress.css +++ /dev/null @@ -1,41 +0,0 @@ -.circular-progress { - position: relative; - display: inline-block; -} - -.circular-progress svg { - transform: rotate(-90deg); -} - -.progress-ring-bg { - fill: none; - stroke: #e2e8f0; - transition: stroke 0.3s ease; -} - -.progress-ring-circle { - fill: none; - stroke-linecap: round; - transition: stroke-dashoffset 0.5s ease; -} - -.progress-value { - font-weight: 700; - fill: var(--text-main); - transform: rotate(90deg); - transform-origin: center; -} - -.progress-label { - fill: var(--text-secondary); - font-weight: 500; - transform: rotate(90deg); - transform-origin: center; -} - -.progress-sublabel { - fill: var(--text-light); - font-weight: 400; - transform: rotate(90deg); - transform-origin: center; -} diff --git a/mengyamonitor-frontend/src/components/Common/CircularProgress.tsx b/mengyamonitor-frontend/src/components/Common/CircularProgress.tsx deleted file mode 100644 index d20a3ec..0000000 --- a/mengyamonitor-frontend/src/components/Common/CircularProgress.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import './CircularProgress.css'; - -interface CircularProgressProps { - value: number; - label: string; - color?: string; - size?: number; - subLabel?: string; -} - -export const CircularProgress = ({ - value, - label, - color = 'var(--primary-color)', - size = 150, - subLabel -}: CircularProgressProps) => { - const radius = size * 0.36; // 36% of size - const circumference = 2 * Math.PI * radius; - const offset = circumference - (value / 100) * circumference; - const center = size / 2; - - // 根据尺寸动态计算字体大小 - const valueFontSize = size * 0.12; // 12% of size - const subLabelFontSize = size * 0.08; // 8% of size - const labelFontSize = size * 0.09; // 9% of size - - return ( -
- - - - {subLabel ? ( - - {subLabel} - - ) : ( - - {Math.round(value)}% - - )} - - {label} - - -
- ); -}; diff --git a/mengyamonitor-frontend/src/components/RandomBackdrop/RandomBackdrop.css b/mengyamonitor-frontend/src/components/RandomBackdrop/RandomBackdrop.css new file mode 100644 index 0000000..646762b --- /dev/null +++ b/mengyamonitor-frontend/src/components/RandomBackdrop/RandomBackdrop.css @@ -0,0 +1,29 @@ +.random-backdrop { + position: fixed; + inset: 0; + z-index: 0; /* 低于 #root (z-index: 1),保证监控卡片与顶栏在上层 */ + pointer-events: none; + overflow: hidden; +} + +/* 略放大并外扩,减轻模糊后边缘露底 */ +.random-backdrop__image { + position: absolute; + inset: -16px; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + transform: scale(1.04); + /* 需求「10%」按常用约定映射为 10px 半径(CSS blur 无百分比) */ + filter: blur(10px); + -webkit-filter: blur(10px); +} + +.random-backdrop__fallback { + position: absolute; + inset: 0; + background: + radial-gradient(120% 80% at 20% 20%, rgba(252, 231, 243, 0.95) 0%, transparent 55%), + radial-gradient(100% 90% at 80% 30%, rgba(224, 231, 255, 0.9) 0%, transparent 50%), + linear-gradient(160deg, #fdf4ff 0%, #e0f2fe 45%, #fce7f3 100%); +} diff --git a/mengyamonitor-frontend/src/components/RandomBackdrop/RandomBackdrop.tsx b/mengyamonitor-frontend/src/components/RandomBackdrop/RandomBackdrop.tsx new file mode 100644 index 0000000..08ab9a2 --- /dev/null +++ b/mengyamonitor-frontend/src/components/RandomBackdrop/RandomBackdrop.tsx @@ -0,0 +1,49 @@ +import { useState, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import './RandomBackdrop.css'; + +const RANDBG_JSON = + 'https://randbg.api.smyhub.com/api/random?format=json&mode=auto'; + +type RandomBgResponse = { + url?: string; +}; + +/** 挂到 body,避免与 .app 内 filter/层叠上下文叠在一起盖住卡片 */ +export const RandomBackdrop = () => { + const [imageUrl, setImageUrl] = useState(null); + + useEffect(() => { + let cancelled = false; + void fetch(RANDBG_JSON) + .then((r) => { + if (!r.ok) throw new Error(String(r.status)); + return r.json() as Promise; + }) + .then((data) => { + const u = data?.url; + if (!cancelled && typeof u === 'string' && u.length > 0) { + setImageUrl(u); + } + }) + .catch(() => { + /* 保持 fallback 底色 */ + }); + return () => { + cancelled = true; + }; + }, []); + + return createPortal( +
+ {!imageUrl &&
} + {imageUrl && ( +
+ )} +
, + document.body + ); +}; diff --git a/mengyamonitor-frontend/src/components/ServerCard/ServerCard.css b/mengyamonitor-frontend/src/components/ServerCard/ServerCard.css index e69f0b2..6e0e634 100644 --- a/mengyamonitor-frontend/src/components/ServerCard/ServerCard.css +++ b/mengyamonitor-frontend/src/components/ServerCard/ServerCard.css @@ -1,23 +1,27 @@ .server-card { background: var(--surface-color); - border-radius: var(--radius-lg); - padding: 1.5rem; + backdrop-filter: var(--backdrop-blur); + -webkit-backdrop-filter: var(--backdrop-blur); + border-radius: var(--radius-glass); + padding: 0.85rem 1rem; box-shadow: var(--shadow-sm); transition: all 0.3s ease; - border: 1px solid var(--border-color); + border: 1px solid var(--glass-border); display: flex; flex-direction: column; - height: 100%; + min-height: 248px; + height: auto; } .server-card:hover { - box-shadow: var(--shadow-lg); - transform: translateY(-4px); - border-color: var(--primary-color); + box-shadow: var(--shadow-md); + border-color: rgba(255, 255, 255, 0.75); + background: var(--surface-hover); } .server-card.online { - border-top: 4px solid var(--success-color); + border-top: 3px solid var(--primary-color); + box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.6); } .server-card.offline { @@ -29,13 +33,7 @@ display: flex; justify-content: space-between; align-items: flex-start; - margin-bottom: 0.5rem; - cursor: grab; - user-select: none; -} - -.card-header:active { - cursor: grabbing; + margin-bottom: 0.35rem; } .server-info { @@ -53,25 +51,6 @@ .status-online { background: var(--success-color); - box-shadow: 0 0 0 4px rgba(134, 239, 172, 0.2); -} - -.status-online::after { - content: ''; - position: absolute; - top: -4px; - left: -4px; - right: -4px; - bottom: -4px; - border-radius: 50%; - animation: pulse 2s infinite; - border: 1px solid var(--success-color); -} - -@keyframes pulse { - 0% { transform: scale(1); opacity: 0.5; } - 70% { transform: scale(1.5); opacity: 0; } - 100% { transform: scale(1); opacity: 0; } } .status-offline { @@ -86,9 +65,10 @@ .server-name { margin: 0; - font-size: 1.125rem; - font-weight: 600; + font-size: 1rem; + font-weight: 700; color: var(--text-main); + letter-spacing: -0.02em; } .server-hostname { @@ -124,62 +104,68 @@ flex: 1; display: flex; flex-direction: column; - gap: 1rem; + gap: 0.5rem; } -.metrics-grid-main { +.card-stats-compact { display: flex; - justify-content: space-around; - align-items: center; - padding: 0.5rem 0; + flex-direction: column; + gap: 0.28rem; + padding: 0.45rem 0.55rem; + background: rgba(255, 255, 255, 0.35); + border-radius: var(--radius-md); + border: 1px solid rgba(255, 255, 255, 0.5); + font-size: 0.78rem; } -.circular-progress-small { +.stat-line { display: flex; - align-items: center; - justify-content: center; + justify-content: space-between; + align-items: baseline; + gap: 0.75rem; } -.circular-progress-small .progress-ring-bg { - fill: none; - stroke: #e2e8f0; - stroke-width: 6; +.stat-k { + color: var(--text-secondary); + font-weight: 500; + flex-shrink: 0; } -.circular-progress-small .progress-ring-circle { - fill: none; - stroke-width: 6; - stroke-linecap: round; - transition: stroke-dashoffset 0.5s ease; - transform: rotate(-90deg); - transform-origin: center; -} - -.circular-progress-small .progress-value-small { - font-size: 14px; +.stat-v { + color: var(--text-main); font-weight: 600; - fill: var(--text-main); + font-variant-numeric: tabular-nums; + text-align: right; + word-break: break-word; } -.circular-progress-small .progress-label-small { - font-size: 10px; - fill: var(--text-secondary); +.lat-good { + color: var(--success-color); +} + +.lat-mid { + color: #fde047; +} + +.lat-bad { + color: #f87171; } /* Info Grid */ .card-info-grid { display: grid; grid-template-columns: 1fr 1fr; - gap: 0.75rem; - padding: 0.75rem; - background: var(--bg-color); + gap: 0.45rem; + padding: 0.45rem 0.5rem; + background: rgba(255, 255, 255, 0.35); border-radius: var(--radius-md); + border: 1px solid rgba(255, 255, 255, 0.45); } .info-section { display: flex; flex-direction: column; - gap: 0.5rem; + gap: 0.3rem; } .info-row { @@ -215,10 +201,11 @@ .card-performance-grid { display: grid; grid-template-columns: 1fr 1fr; - gap: 0.75rem; - padding: 0.75rem; - background: var(--bg-color); + gap: 0.45rem; + padding: 0.45rem 0.5rem; + background: rgba(255, 255, 255, 0.35); border-radius: var(--radius-md); + border: 1px solid rgba(255, 255, 255, 0.45); } .performance-section { @@ -256,9 +243,9 @@ } .performance-item-value { - color: var(--primary-color); + color: #0284c7; font-weight: 600; - font-family: monospace; + font-family: ui-monospace, monospace; text-align: right; } @@ -267,10 +254,11 @@ display: flex; justify-content: space-between; align-items: center; - padding: 0.75rem; - background: var(--bg-color); + padding: 0.45rem 0.55rem; + background: rgba(255, 255, 255, 0.35); border-radius: var(--radius-md); - margin-top: 0.5rem; + border: 1px solid rgba(255, 255, 255, 0.45); + margin-top: 0.15rem; } .footer-row { @@ -325,13 +313,13 @@ width: 100%; height: 8px; background: var(--bg-color); - border-radius: 4px; + border-radius: var(--radius-sm); overflow: hidden; } .progress-bar-fill { height: 100%; - border-radius: 4px; + border-radius: var(--radius-sm); transition: width 0.5s ease; } @@ -357,31 +345,40 @@ display: flex; align-items: center; justify-content: center; - background: var(--bg-color); + background: rgba(255, 255, 255, 0.3); border-radius: var(--radius-md); - margin-bottom: 1.5rem; + margin-bottom: 0.65rem; + padding: 0.65rem; color: var(--text-light); + border: 1px dashed rgba(255, 255, 255, 0.55); +} + +.offline-state.sync-pending { + border: 1px dashed var(--primary-color); + color: var(--text-secondary); } .card-footer { - margin-top: auto; + margin-top: 0.45rem; } .btn-detail { width: 100%; - background: var(--surface-hover); - border: 1px solid var(--border-color); - padding: 0.75rem; + background: rgba(255, 255, 255, 0.45); + border: 1px solid var(--glass-border); + padding: 0.5rem 0.65rem; border-radius: var(--radius-md); - font-size: 0.875rem; + font-size: 0.8rem; font-weight: 600; color: var(--text-secondary); cursor: pointer; transition: all 0.2s; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); } .btn-detail:hover { - background: var(--primary-color); - color: white; - border-color: var(--primary-color); + background: linear-gradient(135deg, rgba(125, 211, 252, 0.85), rgba(196, 181, 253, 0.75)); + color: #0f172a; + border-color: rgba(255, 255, 255, 0.65); } diff --git a/mengyamonitor-frontend/src/components/ServerCard/ServerCard.tsx b/mengyamonitor-frontend/src/components/ServerCard/ServerCard.tsx index 9d3c27b..c422c57 100644 --- a/mengyamonitor-frontend/src/components/ServerCard/ServerCard.tsx +++ b/mengyamonitor-frontend/src/components/ServerCard/ServerCard.tsx @@ -1,79 +1,7 @@ import type { ServerConfig, ServerMetrics } from '../../types'; -import { formatUptime, formatBytes } from '../../utils/format'; -import { useSortable } from '@dnd-kit/sortable'; -import { CSS } from '@dnd-kit/utilities'; +import { formatUptime, formatBytes, formatPercent, formatLatencyMs } from '../../utils/format'; import './ServerCard.css'; -const CircularProgress = ({ value, label, color, size = 60 }: { value: number; label: string; color: string; size?: number }) => { - const radius = size * 0.36; - const circumference = 2 * Math.PI * radius; - const offset = circumference - (value / 100) * circumference; - - return ( -
- - - - - {Math.round(value)}% - - - {label} - - -
- ); -}; - -const LoadProgress = ({ value, maxValue = 1.0, size = 80 }: { value: number; maxValue?: number; size?: number }) => { - const percentage = Math.min((value / maxValue) * 100, 100); - const radius = size * 0.36; - const circumference = 2 * Math.PI * radius; - const offset = circumference - (percentage / 100) * circumference; - const color = percentage > 80 ? '#f87171' : percentage > 50 ? '#fde047' : '#86efac'; - - return ( -
- - - - - {value.toFixed(2)} - - - 负载 - - -
- ); -}; - export interface ServerCardProps { server: ServerConfig; online: boolean; @@ -83,7 +11,6 @@ export interface ServerCardProps { storageUsage?: number; uptime?: number; onDetail: (serverId: string) => void; - onRemove: (serverId: string) => void; } export const ServerCard = ({ @@ -95,24 +22,7 @@ export const ServerCard = ({ storageUsage = 0, uptime, onDetail, - onRemove, }: ServerCardProps) => { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: server.id }); - - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - }; - - // 从 URL 提取 IP 地址 const extractIP = (url: string): string => { try { const urlObj = new URL(url); @@ -122,11 +32,10 @@ export const ServerCard = ({ } }; - // 获取主 IP 地址(从网络接口) const getMainIP = (): string => { if (metrics?.network && metrics.network.length > 0) { - const mainInterface = metrics.network.find(iface => - iface.ipAddress && iface.ipAddress !== 'N/A' && !iface.ipAddress.startsWith('127.') + const mainInterface = metrics.network.find( + (iface) => iface.ipAddress && iface.ipAddress !== 'N/A' && !iface.ipAddress.startsWith('127.') ); return mainInterface?.ipAddress || extractIP(server.url); } @@ -144,74 +53,84 @@ export const ServerCard = ({ const diskWriteSpeed = metrics?.system?.diskWriteSpeed || 0; const networkRxSpeed = metrics?.system?.networkRxSpeed || 0; const networkTxSpeed = metrics?.system?.networkTxSpeed || 0; - const latency = metrics?.latency?.clientToServer || -1; + const latency = metrics?.latency?.clientToServer ?? -1; const processCount = metrics?.system?.processCount || 0; const packageCount = metrics?.system?.packageCount || 0; + const loggedInUsers = metrics?.system?.loggedInUsers; + const tcpConnections = metrics?.system?.tcpConnections; + const swapTotalBytes = metrics?.memory?.swapTotalBytes; + const swapType = metrics?.memory?.swapType; - // 延迟颜色判断 - const getLatencyColor = (latency: number): string => { - if (latency < 0) return '#ef4444'; // 超时/失败 - 红色 - if (latency < 50) return '#86efac'; // 低延迟 - 绿色 - if (latency < 200) return '#fde047'; // 中延迟 - 黄色 - return '#f87171'; // 高延迟 - 红色 - }; + const loadRatio = cpuCores > 0 ? (loadAverage / cpuCores) * 100 : 0; - const latencyColor = getLatencyColor(latency); + const latencyClass = + !Number.isFinite(latency) || latency < 0 + ? 'lat-bad' + : latency < 50 + ? 'lat-good' + : latency < 200 + ? 'lat-mid' + : 'lat-bad'; return ( -
-
+
+

{server.name}

- {online && hostname !== 'Unknown' && ( - {hostname} - )} + {online && hostname !== 'Unknown' && {hostname}}
-
- {online && metrics ? ( + {!online ? ( +
+

服务器连接断开

+
+ ) : !metrics ? ( +
+

正在从中心同步指标…

+
+ ) : ( <>
-
+
{loadAverage > 0 && ( - +
+ 负载 + + {loadAverage.toFixed(2)} / {cpuCores} 核(≈ {formatPercent(loadRatio)}) + +
+ )} +
+ CPU + {formatPercent(cpuUsage)} +
+
+ 内存 + {formatPercent(memoryUsage)} +
+
+ 存储峰值 + {formatPercent(storageUsage)} +
+ {swapTotalBytes !== undefined && ( +
+ Swap + + {formatBytes(swapTotalBytes)} + {swapType ? ` · ${swapType}` : ''} + +
+ )} + {tcpConnections !== undefined && ( +
+ TCP + {tcpConnections} +
)} - 80 ? '#f87171' : '#86efac'} - size={80} - /> - 80 ? '#fde047' : '#6ee7b7'} - size={80} - /> - 90 ? '#f87171' : '#a7f3d0'} - size={80} - />
@@ -239,6 +158,12 @@ export const ServerCard = ({ 进程 {processCount}
+ {loggedInUsers !== undefined && ( +
+ 会话 + {loggedInUsers} +
+ )}
软件包 {packageCount} @@ -254,11 +179,15 @@ export const ServerCard = ({
读取 - {diskReadSpeed > 0 ? formatBytes(diskReadSpeed * 1024 * 1024) + '/s' : '0 B/s'} + + {diskReadSpeed > 0 ? formatBytes(diskReadSpeed * 1024 * 1024) + '/s' : '0 B/s'} + 写入 - {diskWriteSpeed > 0 ? formatBytes(diskWriteSpeed * 1024 * 1024) + '/s' : '0 B/s'} + + {diskWriteSpeed > 0 ? formatBytes(diskWriteSpeed * 1024 * 1024) + '/s' : '0 B/s'} +
@@ -270,11 +199,15 @@ export const ServerCard = ({
下载 - {networkRxSpeed > 0 ? formatBytes(networkRxSpeed * 1024 * 1024) + '/s' : '0 B/s'} + + {networkRxSpeed > 0 ? formatBytes(networkRxSpeed * 1024 * 1024) + '/s' : '0 B/s'} + 上传 - {networkTxSpeed > 0 ? formatBytes(networkTxSpeed * 1024 * 1024) + '/s' : '0 B/s'} + + {networkTxSpeed > 0 ? formatBytes(networkTxSpeed * 1024 * 1024) + '/s' : '0 B/s'} +
@@ -285,23 +218,17 @@ export const ServerCard = ({ 运行时间 {uptime !== undefined ? formatUptime(uptime) : 'N/A'}
-
- 延迟 - - {latency > 0 ? `${latency} ms` : latency === -1 ? '超时' : 'N/A'} - +
+ 采集器→中心 + {formatLatencyMs(latency)}
- ) : ( -
-

服务器连接断开

-
)} - +
-
diff --git a/mengyamonitor-frontend/src/components/ServerDetail/ServerDetail.css b/mengyamonitor-frontend/src/components/ServerDetail/ServerDetail.css index a26e541..e6b6b1b 100644 --- a/mengyamonitor-frontend/src/components/ServerDetail/ServerDetail.css +++ b/mengyamonitor-frontend/src/components/ServerDetail/ServerDetail.css @@ -4,14 +4,14 @@ left: 0; right: 0; bottom: 0; - background: rgba(0, 0, 0, 0.75); - backdrop-filter: blur(8px); + background: rgba(30, 27, 46, 0.38); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 1000; - padding: 20px; - animation: fadeIn 0.3s cubic-bezier(0.4, 0, 0.2, 1); + padding: 12px; } @keyframes fadeIn { @@ -25,14 +25,15 @@ .modal-content { background: var(--surface-color); - border-radius: 24px; + backdrop-filter: var(--backdrop-blur); + -webkit-backdrop-filter: var(--backdrop-blur); + border-radius: var(--radius-glass); width: 100%; - max-width: 1200px; - max-height: 90vh; + max-width: 1100px; + max-height: 92vh; overflow: hidden; box-shadow: var(--shadow-lg); - border: 1px solid var(--border-color); - animation: slideUp 0.4s cubic-bezier(0.4, 0, 0.2, 1); + border: 1px solid var(--glass-border); display: flex; flex-direction: column; } @@ -46,32 +47,34 @@ display: flex; justify-content: space-between; align-items: center; - padding: 24px 32px; - border-bottom: 1px solid var(--border-color); + padding: 12px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.45); position: sticky; top: 0; - background: var(--surface-color); + background: rgba(255, 255, 255, 0.35); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); z-index: 10; } .modal-header h2 { margin: 0; - font-size: 20px; + font-size: 1.05rem; font-weight: 700; color: var(--text-main); - letter-spacing: -0.2px; + letter-spacing: -0.03em; } .btn-close { - background: var(--surface-hover); - border: 1px solid var(--border-color); - font-size: 20px; + background: rgba(255, 255, 255, 0.45); + border: 1px solid var(--glass-border); + font-size: 18px; color: var(--text-secondary); cursor: pointer; padding: 0; - width: 36px; - height: 36px; - border-radius: 10px; + width: 32px; + height: 32px; + border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center; @@ -85,7 +88,7 @@ } .modal-body { - padding: 24px; + padding: 10px 12px 12px; overflow-y: auto; flex: 1; } @@ -100,7 +103,7 @@ .modal-body::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); - border-radius: 10px; + border-radius: var(--radius-sm); } .modal-body::-webkit-scrollbar-thumb:hover { @@ -108,169 +111,329 @@ } .detail-section { - margin-bottom: 14px; - background: var(--surface-color); - border-radius: 12px; - padding: 14px; - border: 1px solid var(--border-color); - transition: all 0.3s ease; -} - -.detail-section:hover { - background: var(--surface-hover); - border-color: var(--border-color); + margin-bottom: 8px; + background: rgba(255, 255, 255, 0.28); + border-radius: var(--radius-lg); + padding: 10px 11px; + border: 1px solid rgba(255, 255, 255, 0.52); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); } .detail-section:last-child { margin-bottom: 0; } -.detail-section h3 { - margin: 0 0 12px 0; - font-size: 15px; - font-weight: 600; - color: var(--text-main); +.detail-category-heading { + margin: 0 0 8px 0; + font-size: 0.8125rem; + font-weight: 700; + color: #334155; + letter-spacing: -0.02em; display: flex; align-items: center; gap: 8px; } -.detail-section h3::before { +.detail-category-heading::before { content: ''; width: 4px; - height: 20px; - background: linear-gradient(135deg, #86efac 0%, #a7f3d0 100%); - border-radius: 2px; + height: 14px; + border-radius: var(--radius-sm); + background: linear-gradient(180deg, var(--primary-color), var(--secondary-color)); + flex-shrink: 0; +} + +.detail-subsection { + margin-top: 6px; +} + +.detail-subsection:first-of-type { + margin-top: 0; +} + +.detail-subsection + .detail-subsection { + margin-top: 10px; + padding-top: 8px; + border-top: 1px dashed rgba(100, 116, 139, 0.28); +} + +.detail-subheading { + margin: 0 0 6px 0; + font-size: 0.72rem; + font-weight: 700; + color: var(--text-secondary); + text-transform: none; + letter-spacing: 0.04em; + padding: 3px 10px; + width: fit-content; + max-width: 100%; + border-radius: var(--radius-lg); + background: rgba(255, 255, 255, 0.55); + border: 1px solid rgba(196, 181, 253, 0.35); +} + +.detail-subheading--inline { + margin-bottom: 4px; +} + +.detail-subheading--minor { + font-size: 0.68rem; + margin-top: 8px; + margin-bottom: 6px; + background: rgba(255, 255, 255, 0.4); + border-color: rgba(125, 211, 252, 0.35); } .detail-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); - gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 6px; } -.cpu-section-content .detail-grid, -.memory-section-content .detail-grid { - grid-template-columns: repeat(2, 1fr); - width: 100%; +.detail-grid--cpu { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +@media (min-width: 640px) { + .detail-grid--cpu { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +.detail-item--span-full { + grid-column: 1 / -1; } .detail-item { display: flex; - flex-direction: column; - gap: 6px; - padding: 12px 14px; - background: var(--surface-hover); - border-radius: 10px; - border: 1px solid var(--border-color); - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + flex-direction: row; + align-items: baseline; + justify-content: space-between; + gap: 10px; + padding: 6px 8px; + background: rgba(255, 255, 255, 0.42); + border-radius: var(--radius-md); + border: 1px solid rgba(255, 255, 255, 0.55); + transition: border-color 0.2s ease; } .detail-item:hover { - background: var(--bg-color); - border-color: var(--border-color); + border-color: rgba(125, 211, 252, 0.45); } .item-label { - font-size: 12px; + font-size: 11px; color: var(--text-secondary); font-weight: 600; - letter-spacing: 0.5px; + letter-spacing: 0.02em; + flex-shrink: 0; } .item-value { - font-size: 15px; + font-size: 13px; color: var(--text-main); font-weight: 700; - font-family: 'SF Mono', 'Consolas', 'Monaco', monospace; - letter-spacing: -0.2px; + font-family: ui-monospace, 'SF Mono', Consolas, monospace; + letter-spacing: -0.02em; + text-align: right; + word-break: break-word; } +.item-value.emphasize, .item-value.highlight { - background: linear-gradient(135deg, #86efac 0%, #a7f3d0 100%); - -webkit-background-clip: text; - background-clip: text; - -webkit-text-fill-color: transparent; - font-size: 20px; + color: #0284c7; + font-size: 14px; + font-weight: 700; + -webkit-text-fill-color: unset; + background: none; +} + +.item-sub { + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); +} + +.detail-plain { + margin: 0; +} + +.per-core-plain { + margin-top: 8px; + padding-top: 8px; + border-top: 1px dashed rgba(100, 116, 139, 0.22); +} + +.per-core-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(118px, 1fr)); + gap: 6px; + margin-top: 6px; +} + +.per-core-chip { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 4px; + padding: 8px 10px; + background: rgba(255, 255, 255, 0.42); + border: 1px solid rgba(255, 255, 255, 0.55); + border-radius: var(--radius-md); + transition: border-color 0.2s ease, background 0.2s ease; +} + +.per-core-chip:hover { + border-color: rgba(125, 211, 252, 0.45); + background: rgba(255, 255, 255, 0.55); +} + +.per-core-name { + font-size: 10px; + font-weight: 700; + color: var(--text-secondary); + letter-spacing: 0.04em; +} + +.per-core-pct { + font-size: 13px; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: var(--text-main); +} + +.storage-plain-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.storage-plain-row { + padding: 6px 8px; + background: rgba(255, 255, 255, 0.4); + border: 1px solid rgba(255, 255, 255, 0.55); + border-radius: var(--radius-md); + font-size: 12px; +} + +.storage-plain-head { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 4px; +} + +.storage-mount { + font-weight: 600; + color: var(--text-main); +} + +.storage-pct { + font-weight: 700; + font-variant-numeric: tabular-nums; + color: var(--text-main); +} + +.storage-meta { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.4; } /* System Info Styles */ .system-info-container { display: flex; flex-direction: column; - gap: 14px; + gap: 8px; } .system-info-group { display: flex; flex-direction: column; - gap: 8px; + gap: 5px; +} + +.latency-section-hint { + margin: 0 0 8px 0; + font-size: 0.68rem; + line-height: 1.45; + color: var(--text-secondary); + max-width: 72ch; } .info-group-title { margin: 0; - font-size: 12px; + font-size: 0.72rem; font-weight: 700; color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 1px; - padding-bottom: 6px; - border-bottom: 1px solid var(--border-color); + text-transform: none; + letter-spacing: 0.04em; + padding: 3px 10px; + width: fit-content; + border-radius: var(--radius-lg); + background: rgba(255, 255, 255, 0.55); + border: 1px solid rgba(249, 168, 212, 0.35); } .system-info-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 8px; + gap: 6px; +} + +.system-info-grid--cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.system-info-grid--cols-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.system-info-grid--cols-4 .system-info-item { + min-height: 54px; + align-items: center; +} + +.system-info-grid--cols-4 .system-info-content:not(.system-info-content--stack) { + align-items: center; +} + +.system-info-grid--cols-4 .system-info-content--stack { + align-items: flex-start; } .system-info-item { display: flex; align-items: flex-start; - padding: 10px 12px; - background: #ffffff; - border-radius: 8px; - border: 1px solid var(--border-color); + min-height: 0; + padding: 6px 8px; + background: rgba(255, 255, 255, 0.42); + border-radius: var(--radius-md); + border: 1px solid rgba(255, 255, 255, 0.55); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); position: relative; overflow: hidden; } -.system-info-item::before { - content: ''; - position: absolute; - top: 0; - left: 0; - width: 3px; - height: 100%; - background: linear-gradient(180deg, #86efac 0%, #a7f3d0 100%); - opacity: 0; - transition: opacity 0.3s ease; -} - .system-info-item:hover { - transform: translateY(-2px); - box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08); - border-color: rgba(134, 239, 172, 0.3); + border-color: var(--border-color); background: #ffffff; } -.system-info-item:hover::before { - opacity: 1; -} - .system-info-item-full { grid-column: 1 / -1; } .system-info-item-highlight { - background: #ffffff; - border-color: rgba(134, 239, 172, 0.2); + border-color: rgba(125, 211, 252, 0.45); + background: rgba(255, 255, 255, 0.52); } .system-info-item-highlight:hover { - background: #ffffff; - border-color: rgba(134, 239, 172, 0.4); + border-color: rgba(125, 211, 252, 0.6); } .system-info-item-temperature { @@ -286,34 +449,57 @@ .system-info-content { flex: 1; display: flex; - flex-direction: column; - gap: 4px; + flex-direction: row; + align-items: baseline; + justify-content: space-between; + gap: 8px; min-width: 0; width: 100%; } -.system-info-label { +.system-info-content--stack { + flex-direction: column; + align-items: stretch; + gap: 2px; +} + +.system-info-content--stack .system-info-value { + margin-top: 2px; + text-align: left; +} + +.system-info-sublabel { font-size: 10px; + font-weight: 500; + color: var(--text-secondary); + opacity: 0.92; + line-height: 1.25; +} + +.system-info-label { + font-size: 11px; color: var(--text-secondary); font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; + text-transform: none; + letter-spacing: 0.02em; + flex-shrink: 0; } .system-info-value { - font-size: 13px; + font-size: 12px; color: var(--text-main); font-weight: 700; word-break: break-word; line-height: 1.3; + text-align: right; } .system-info-value.highlight { - background: linear-gradient(135deg, #86efac 0%, #a7f3d0 100%); - -webkit-background-clip: text; - background-clip: text; - -webkit-text-fill-color: transparent; - font-size: 16px; + color: #0284c7; + font-size: 13px; + font-weight: 700; + -webkit-text-fill-color: unset; + background: none; } .system-info-value-long { @@ -337,22 +523,27 @@ } @media (max-width: 768px) { - .system-info-grid { + .system-info-grid--cols-3, + .system-info-grid--cols-4 { grid-template-columns: 1fr; } - + .system-info-item-full { grid-column: 1; } + + .detail-grid--cpu { + grid-template-columns: 1fr; + } } /* GPU Items */ .gpu-item { - margin-bottom: 1.5rem; - padding: 1.5rem; - background: var(--surface-hover); - border-radius: var(--radius-lg); - border: 1px solid var(--border-color); + margin-bottom: 8px; + padding: 8px 10px; + background: rgba(255, 255, 255, 0.38); + border-radius: var(--radius-md); + border: 1px solid rgba(255, 255, 255, 0.55); } .gpu-item:last-child { @@ -363,25 +554,25 @@ display: flex; justify-content: space-between; align-items: center; - margin-bottom: 1rem; + margin-bottom: 6px; } .gpu-header strong { color: var(--text-main); - font-size: 1rem; + font-size: 0.9rem; } .gpu-status { padding: 4px 12px; - border-radius: 12px; + border-radius: var(--radius-md); font-size: 0.75rem; font-weight: 600; text-transform: uppercase; } .gpu-status.ok { - background: rgba(134, 239, 172, 0.1); - color: var(--success-color); + background: rgba(125, 211, 252, 0.2); + color: #0369a1; } .gpu-status.error { @@ -407,14 +598,14 @@ flex: 1; height: 8px; background: var(--bg-color); - border-radius: 4px; + border-radius: var(--radius-sm); overflow: hidden; } .gpu-bar { height: 100%; background: linear-gradient(90deg, #a7f3d0, #d1fae5); - border-radius: 4px; + border-radius: var(--radius-sm); transition: width 0.5s ease; } @@ -515,26 +706,6 @@ grid-template-columns: 1fr; } - .cpu-section-content, - .memory-section-content { - flex-direction: column; - text-align: center; - } - - .cpu-circular-group { - flex-direction: column; - align-items: center; - } - - .gpu-bar-container { - flex-wrap: wrap; - gap: 0.5rem; - } - - .gpu-bar-bg { - min-width: 100%; - order: 3; - } } /* Docker Containers Section */ @@ -542,7 +713,7 @@ margin-top: 1rem; padding: 1rem; background: #f8f9fa; - border-radius: 8px; + border-radius: var(--radius-lg); } .containers-section h4 { @@ -563,7 +734,7 @@ margin-top: 1rem; padding: 1rem; background: #f8f9fa; - border-radius: 8px; + border-radius: var(--radius-lg); } .docker-images-section h4 { @@ -575,18 +746,15 @@ /* Docker Containers and Images Names */ .docker-containers-section { - margin-top: 16px; - padding: 16px; - background: var(--surface-hover); - border-radius: 12px; - border: 1px solid var(--border-color); + margin-top: 6px; + padding: 8px 10px; + background: rgba(255, 255, 255, 0.35); + border-radius: var(--radius-md); + border: 1px solid rgba(255, 255, 255, 0.5); } .docker-containers-section h4 { - margin: 0 0 12px 0; - font-size: 14px; - color: var(--text-main); - font-weight: 600; + margin: 0 0 6px 0; } .docker-names-list { @@ -598,7 +766,7 @@ .docker-name-tag { display: inline-block; padding: 6px 12px; - border-radius: 8px; + border-radius: var(--radius-md); font-size: 12px; font-weight: 500; font-family: 'SF Mono', 'Consolas', 'Monaco', monospace; @@ -630,16 +798,16 @@ /* System Logs Section */ .logs-container { - background: #0a0c10; - color: rgba(255, 255, 255, 0.8); - border-radius: 12px; - padding: 20px; - font-family: 'SF Mono', 'Consolas', 'Monaco', monospace; - font-size: 13px; - max-height: 400px; + background: rgba(15, 23, 42, 0.78); + color: rgba(248, 250, 252, 0.88); + border-radius: var(--radius-md); + padding: 10px 12px; + font-family: ui-monospace, 'SF Mono', Consolas, monospace; + font-size: 11px; + max-height: 280px; overflow-y: auto; - border: 1px solid rgba(255, 255, 255, 0.1); - line-height: 1.7; + border: 1px solid rgba(255, 255, 255, 0.12); + line-height: 1.55; } .logs-container::-webkit-scrollbar { @@ -648,7 +816,7 @@ .logs-container::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.2); - border-radius: 10px; + border-radius: var(--radius-sm); } .log-line { @@ -668,114 +836,120 @@ border-bottom: none; } -/* Process List Styles */ -.process-list { - display: flex; - flex-direction: column; - gap: 8px; -} - -.process-item { - display: flex; - gap: 12px; - padding: 12px 14px; - background: #ffffff; - border-radius: 12px; - border: 1px solid var(--border-color); - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - position: relative; - overflow: hidden; -} - -.process-item::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 3px; - background: linear-gradient(90deg, #86efac 0%, #a7f3d0 50%, #d1fae5 100%); - opacity: 0; - transition: opacity 0.3s ease; -} - -.process-item:hover { - transform: translateY(-2px); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); - border-color: rgba(134, 239, 172, 0.3); - background: #ffffff; -} - -.process-item:hover::before { - opacity: 1; -} - -.process-rank { - display: flex; - align-items: center; - justify-content: center; - min-width: 32px; - height: 32px; - background: linear-gradient(135deg, #86efac 0%, #a7f3d0 100%); - color: white; - font-weight: 700; - font-size: 14px; - border-radius: 8px; - box-shadow: 0 2px 8px rgba(134, 239, 172, 0.3); - flex-shrink: 0; -} - -.process-item:nth-child(1) .process-rank { - background: linear-gradient(135deg, #fde047 0%, #facc15 100%); - box-shadow: 0 4px 12px rgba(253, 224, 71, 0.3); -} - -.process-item:nth-child(2) .process-rank { - background: linear-gradient(135deg, #a7f3d0 0%, #86efac 100%); - box-shadow: 0 4px 12px rgba(167, 243, 208, 0.3); -} - -.process-item:nth-child(3) .process-rank { - background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%); - box-shadow: 0 4px 12px rgba(209, 250, 229, 0.3); -} - -.process-content { - flex: 1; - display: flex; - flex-direction: column; - gap: 10px; -} - -.process-header { - display: flex; - align-items: center; - justify-content: space-between; -} - -.process-name-group { +/* Process — 每进程一行 */ +.process-compact { display: flex; flex-direction: column; gap: 3px; + font-size: 12px; + min-width: 0; } -.process-name { - font-size: 14px; +.process-compact__head, +.process-compact__row { + display: grid; + grid-template-columns: + 1.75rem + minmax(0, 1fr) + minmax(3.25rem, 4.25rem) + minmax(2.75rem, 3.5rem) + minmax(5.5rem, 7.5rem); + gap: 6px 10px; + align-items: center; + padding: 4px 8px; + border-radius: var(--radius-sm); +} + +.process-compact__head { + font-size: 10px; font-weight: 700; - color: var(--text-main); - letter-spacing: -0.3px; + color: var(--text-secondary); + letter-spacing: 0.05em; + padding-bottom: 6px; + margin-bottom: 1px; + border-bottom: 1px dashed rgba(100, 116, 139, 0.28); } -.process-pid { +.process-compact__head span:nth-child(1), +.process-compact__rank { + text-align: center; +} + +.process-compact__head span:nth-child(4), +.process-compact__head span:nth-child(5), +.process-compact__num { + text-align: right; +} + +.process-compact__row { + background: rgba(255, 255, 255, 0.38); + border: 1px solid rgba(255, 255, 255, 0.5); + transition: background 0.15s ease, border-color 0.15s ease; +} + +.process-compact__row:hover { + background: rgba(255, 255, 255, 0.52); + border-color: rgba(125, 211, 252, 0.42); +} + +.process-compact__rank { + font-size: 10px; + font-weight: 800; + font-variant-numeric: tabular-nums; + line-height: 1.25; + padding: 3px 0; + border-radius: var(--radius-sm); + background: linear-gradient(135deg, rgba(125, 211, 252, 0.55), rgba(196, 181, 253, 0.5)); + color: #0f172a; +} + +.process-compact__row:nth-child(2) .process-compact__rank { + background: linear-gradient(135deg, rgba(253, 224, 71, 0.65), rgba(251, 207, 232, 0.55)); +} + +.process-compact__row:nth-child(3) .process-compact__rank { + background: linear-gradient(135deg, rgba(165, 243, 252, 0.55), rgba(125, 211, 252, 0.5)); +} + +.process-compact__row:nth-child(4) .process-compact__rank { + background: linear-gradient(135deg, rgba(233, 213, 255, 0.55), rgba(196, 181, 253, 0.5)); +} + +.process-compact__name { font-size: 11px; + font-weight: 600; + color: var(--text-main); + letter-spacing: -0.02em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.process-compact__pid { + font-family: ui-monospace, 'SF Mono', Consolas, monospace; + font-size: 11px; + font-weight: 600; + font-variant-numeric: tabular-nums; color: var(--text-secondary); +} + +.process-compact__num { + font-family: ui-monospace, 'SF Mono', Consolas, monospace; + font-size: 11px; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--text-main); +} + +.process-compact__mem { + white-space: nowrap; +} + +.process-compact__mem-abs { font-weight: 500; - font-family: 'SF Mono', 'Consolas', 'Monaco', monospace; - background: var(--surface-hover); - padding: 3px 8px; - border-radius: 5px; - display: inline-block; - width: fit-content; + font-size: 10px; + color: var(--text-secondary); } .process-metrics { @@ -815,14 +989,14 @@ width: 100%; height: 6px; background: var(--surface-hover); - border-radius: 4px; + border-radius: var(--radius-sm); overflow: hidden; position: relative; } .metric-bar { height: 100%; - border-radius: 4px; + border-radius: var(--radius-sm); transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1); position: relative; overflow: hidden; @@ -863,7 +1037,7 @@ gap: 6px; padding: 6px 10px; background: var(--surface-hover); - border-radius: 6px; + border-radius: var(--radius-md); border: 1px solid var(--border-color); font-size: 11px; color: var(--text-secondary); @@ -886,203 +1060,131 @@ color: var(--text-main); } -/* Network Interface Styles */ -.network-list { +/* Network — 每接口一行 */ +.network-compact { display: flex; flex-direction: column; - gap: 10px; -} - -.network-item { - padding: 12px 14px; - background: #ffffff; - border-radius: 10px; - border: 1px solid var(--border-color); - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - position: relative; - overflow: hidden; -} - -.network-item::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 3px; - background: linear-gradient(90deg, #86efac 0%, #a7f3d0 50%, #d1fae5 100%); - opacity: 0; - transition: opacity 0.3s ease; -} - -.network-item:hover { - transform: translateY(-2px); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); - border-color: rgba(134, 239, 172, 0.3); - background: #ffffff; -} - -.network-item:hover::before { - opacity: 1; -} - -.network-header { - margin-bottom: 10px; - padding-bottom: 10px; - border-bottom: 1px solid var(--border-color); -} - -.network-name-group { - display: flex; - align-items: center; - gap: 12px; -} - -.network-icon { - font-size: 20px; - width: 36px; - height: 36px; - display: flex; - align-items: center; - justify-content: center; - background: linear-gradient(135deg, #86efac 0%, #a7f3d0 100%); - border-radius: 8px; - flex-shrink: 0; -} - -.network-name-info { - display: flex; - flex-direction: column; - gap: 4px; - flex: 1; -} - -.network-name { - font-size: 15px; - font-weight: 700; - color: var(--text-main); - letter-spacing: -0.3px; -} - -.network-ip { + gap: 3px; font-size: 12px; - font-weight: 500; - font-family: 'SF Mono', 'Consolas', 'Monaco', monospace; - padding: 4px 10px; - border-radius: 6px; - display: inline-block; - width: fit-content; - transition: all 0.3s ease; + min-width: 0; } -.network-ip.has-ip { - background: linear-gradient(135deg, rgba(134, 239, 172, 0.1) 0%, rgba(167, 243, 208, 0.1) 100%); - color: #86efac; - border: 1px solid rgba(134, 239, 172, 0.2); -} - -.network-ip.no-ip { - background: var(--surface-hover); - color: var(--text-secondary); - border: 1px solid var(--border-color); -} - -.network-details { - display: flex; - flex-direction: column; - gap: 10px; -} - -.network-detail-row { +.network-compact__head, +.network-compact__row { display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 10px; -} - -.network-detail-item { - display: flex; - flex-direction: column; - gap: 4px; - padding: 10px 12px; - background: var(--surface-hover); - border-radius: 8px; - border: 1px solid var(--border-color); -} - -.network-detail-label { - font-size: 11px; - color: var(--text-secondary); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.network-detail-value { - font-size: 13px; - font-weight: 700; - color: var(--text-main); - font-family: 'SF Mono', 'Consolas', 'Monaco', monospace; -} - -.network-traffic { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 10px; -} - -.traffic-item { - padding: 10px 12px; - background: var(--surface-hover); - border-radius: 8px; - border: 1px solid var(--border-color); - transition: all 0.3s ease; -} - -.traffic-item:hover { - background: var(--bg-color); - border-color: var(--border-color); -} - -.traffic-header { - display: flex; + grid-template-columns: + minmax(3.75rem, 6rem) + minmax(6.5rem, 1fr) + minmax(7rem, 1.2fr) + minmax(4.25rem, auto) + minmax(4.25rem, auto); + gap: 6px 12px; align-items: center; - justify-content: space-between; - gap: 8px; + padding: 5px 8px; + border-radius: var(--radius-sm); } -.traffic-label { - font-size: 11px; +.network-compact__head { + font-size: 10px; + font-weight: 700; color: var(--text-secondary); - font-weight: 600; + letter-spacing: 0.05em; + padding-bottom: 6px; + margin-bottom: 1px; + border-bottom: 1px dashed rgba(100, 116, 139, 0.28); } -.traffic-value { - font-size: 13px; +.network-compact__head span:nth-child(4), +.network-compact__head span:nth-child(5), +.network-compact__num { + text-align: right; +} + +.network-compact__row { + background: rgba(255, 255, 255, 0.38); + border: 1px solid rgba(255, 255, 255, 0.5); + transition: background 0.15s ease, border-color 0.15s ease; +} + +.network-compact__row:hover { + background: rgba(255, 255, 255, 0.52); + border-color: rgba(125, 211, 252, 0.42); +} + +.network-compact__iface { + font-size: 12px; font-weight: 700; color: var(--text-main); - font-family: 'SF Mono', 'Consolas', 'Monaco', monospace; + letter-spacing: -0.02em; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.network-compact__ip { + font-family: ui-monospace, 'SF Mono', Consolas, monospace; + font-size: 11px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.network-compact__ip--ok { + display: inline-block; + max-width: 100%; + padding: 1px 6px; + border-radius: var(--radius-sm); + background: rgba(125, 211, 252, 0.2); + color: #0369a1; + border: 1px solid rgba(125, 211, 252, 0.35); +} + +.network-compact__placeholder { + color: var(--text-light); + font-size: 11px; +} + +.network-compact__mac { + font-family: ui-monospace, 'SF Mono', Consolas, monospace; + font-size: 11px; + color: var(--text-main); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.network-compact__num { + font-family: ui-monospace, 'SF Mono', Consolas, monospace; + font-size: 11px; + font-weight: 600; + color: var(--text-main); + white-space: nowrap; } @media (max-width: 768px) { .containers-grid { grid-template-columns: 1fr; } - - .process-item { - flex-direction: column; - gap: 10px; + + .process-compact { + overflow-x: auto; + -webkit-overflow-scrolling: touch; } - - .process-rank { - align-self: flex-start; + + .process-compact__head, + .process-compact__row { + min-width: 28rem; } - - .process-metrics { - gap: 8px; + + .network-compact { + overflow-x: auto; + -webkit-overflow-scrolling: touch; } - - .network-detail-row, - .network-traffic { - grid-template-columns: 1fr; + + .network-compact__head, + .network-compact__row { + min-width: 36rem; } } diff --git a/mengyamonitor-frontend/src/components/ServerDetail/ServerDetail.tsx b/mengyamonitor-frontend/src/components/ServerDetail/ServerDetail.tsx index e64b971..3496d6b 100644 --- a/mengyamonitor-frontend/src/components/ServerDetail/ServerDetail.tsx +++ b/mengyamonitor-frontend/src/components/ServerDetail/ServerDetail.tsx @@ -1,6 +1,5 @@ import type { ServerMetrics } from '../../types'; -import { formatBytes, formatUptime, formatPercent } from '../../utils/format'; -import { CircularProgress } from '../Common/CircularProgress'; +import { formatBytes, formatUptime, formatPercent, formatLatencyMs } from '../../utils/format'; import './ServerDetail.css'; interface ServerDetailProps { @@ -20,12 +19,12 @@ export const ServerDetail = ({ metrics, serverName, onClose }: ServerDetailProps
-

系统信息

+

系统与环境

{/* 系统基本信息 */}

基本信息

-
+
主机名 @@ -59,7 +58,7 @@ export const ServerDetail = ({ metrics, serverName, onClose }: ServerDetailProps {/* 运行时信息 */}

运行时信息

-
+
运行时间 @@ -72,6 +71,22 @@ export const ServerDetail = ({ metrics, serverName, onClose }: ServerDetailProps {metrics.system.processCount}
+ {metrics.system.loggedInUsers !== undefined && ( +
+
+ 登录会话 + {metrics.system.loggedInUsers} +
+
+ )} + {metrics.system.tcpConnections !== undefined && ( +
+
+ TCP 连接数 + {metrics.system.tcpConnections} +
+
+ )} {metrics.system.packageCount > 0 && (
@@ -86,7 +101,7 @@ export const ServerDetail = ({ metrics, serverName, onClose }: ServerDetailProps {/* 性能指标 */}

性能指标

-
+
磁盘读取 @@ -129,34 +144,48 @@ export const ServerDetail = ({ metrics, serverName, onClose }: ServerDetailProps {/* 延迟信息 */} {metrics.latency && (
-

延迟检测

-
+

网络延迟(采集端测量)

+

+ 下列指标均在采集器所在机器上探测:第一项为到监控中心 gRPC 地址的 TCP + 建连时间;外网三项为采集端对公网站点的 TCP(:80 / :443)建连耗时,非 ICMP ping。 +

+
-
- 客户端到服务器 +
+ 采集器到监控中心 + 与 CENTRAL_GRPC 的 TCP 连通 - {metrics.latency.clientToServer >= 0 ? `${metrics.latency.clientToServer} ms` : '超时'} + {formatLatencyMs(metrics.latency.clientToServer)}
{metrics.latency.external && ( <>
-
+
百度 (baidu.com) - {metrics.latency.external['baidu.com'] || '超时'} + 采集端 TCP 探测 + + {metrics.latency.external['baidu.com'] || '超时'} +
-
+
谷歌 (google.com) - {metrics.latency.external['google.com'] || '超时'} + 采集端 TCP 探测 + + {metrics.latency.external['google.com'] || '超时'} +
-
+
GitHub (github.com) - {metrics.latency.external['github.com'] || '超时'} + 采集端 TCP 探测 + + {metrics.latency.external['github.com'] || '超时'} +
@@ -168,152 +197,152 @@ export const ServerDetail = ({ metrics, serverName, onClose }: ServerDetailProps
-

CPU

-
-
-
- 80 ? '#f87171' : '#86efac'} - subLabel={formatPercent(metrics.cpu.usagePercent)} - /> -
- {metrics.cpu.loadAverages && metrics.cpu.loadAverages.length > 0 && metrics.cpu.cores > 0 && ( -
- 0.8 ? '#fde047' : '#a7f3d0'} - subLabel={metrics.cpu.loadAverages[0].toFixed(2)} - /> +

处理器与内存

+
+

处理器

+
+
+
+ 型号 + {metrics.cpu.model}
- )} -
-
-
- 型号 - {metrics.cpu.model} -
-
- 核心数 - {metrics.cpu.cores} -
-
- 使用率 - {formatPercent(metrics.cpu.usagePercent)} -
- {metrics.cpu.temperature && metrics.cpu.temperature > 0 && (
- 温度 - {metrics.cpu.temperature.toFixed(1)}°C + 核心数 + {metrics.cpu.cores}
- )} - {metrics.cpu.loadAverages && metrics.cpu.loadAverages.length > 0 && (
- 负载平均值 - {metrics.cpu.loadAverages.join(' / ')} + 使用率 + {formatPercent(metrics.cpu.usagePercent)}
- )} -
-
- - {metrics.cpu.perCoreUsage && metrics.cpu.perCoreUsage.length > 0 && ( -
-

各核心使用率

-
- {metrics.cpu.perCoreUsage.map((core) => ( -
- 80 ? '#f87171' : '#86efac'} - subLabel={formatPercent(core.percent)} - /> + {metrics.cpu.temperature && metrics.cpu.temperature > 0 && ( +
+ 温度 + {metrics.cpu.temperature.toFixed(1)}°C
- ))} + )} + {metrics.cpu.loadAverages && metrics.cpu.loadAverages.length > 0 && ( + <> +
+ 负载 (1 / 5 / 15 min) + {metrics.cpu.loadAverages.map((l) => l.toFixed(2)).join(' / ')} +
+ {metrics.cpu.cores > 0 && ( +
+ 相对核心(1 min 负载) + + {formatPercent(Math.min((metrics.cpu.loadAverages[0] / metrics.cpu.cores) * 100, 100))}{' '} + + ({metrics.cpu.loadAverages[0].toFixed(2)} / {metrics.cpu.cores} 核) + + +
+ )} + + )}
- )} -
+ {metrics.cpu.perCoreUsage && metrics.cpu.perCoreUsage.length > 0 && ( +
+

各核心使用率

+
+ {metrics.cpu.perCoreUsage.map((core) => ( +
+ 核心 {core.core} + {formatPercent(core.percent)} +
+ ))} +
+
+ )} +
-
-

内存

-
-
- 80 ? '#fde047' : '#6ee7b7'} - subLabel={formatPercent(metrics.memory.usedPercent)} - /> -
-
-
- 总容量 - {formatBytes(metrics.memory.totalBytes)} -
-
- 已使用 - {formatBytes(metrics.memory.usedBytes)} -
-
- 可用 - {formatBytes(metrics.memory.freeBytes)} -
-
- 使用率 - {formatPercent(metrics.memory.usedPercent)} +
+

内存

+
+
+
+ 使用率 + {formatPercent(metrics.memory.usedPercent)} +
+
+ 总容量 + {formatBytes(metrics.memory.totalBytes)} +
+
+ 已使用 + {formatBytes(metrics.memory.usedBytes)} +
+
+ 可用 + {formatBytes(metrics.memory.freeBytes)} +
+ {(metrics.memory.swapTotalBytes !== undefined || + metrics.memory.swapType !== undefined) && ( + <> +
+ Swap 总量 + + {formatBytes(metrics.memory.swapTotalBytes ?? 0)} + +
+
+ Swap 已用 + + {formatBytes(metrics.memory.swapUsedBytes ?? 0)} + {metrics.memory.swapUsedPercent !== undefined && ( + ({formatPercent(metrics.memory.swapUsedPercent)}) + )} + +
+
+ Swap 可用 + + {formatBytes(metrics.memory.swapFreeBytes ?? 0)} + +
+
+ Swap 类型 + {metrics.memory.swapType ?? '—'} +
+ + )}
-

存储

-
+

存储与磁盘

+
    {metrics.storage.map((disk, index) => ( -
    - 90 ? '#f87171' : '#a7f3d0'} - subLabel={formatPercent(disk.usedPercent)} - /> -
    -
    总: {formatBytes(disk.totalBytes)}
    -
    用: {formatBytes(disk.usedBytes)}
    -
    -
    +
  • +
    + {disk.mount} + {formatPercent(disk.usedPercent)} +
    +
    + 已用 {formatBytes(disk.usedBytes)} / 共 {formatBytes(disk.totalBytes)} · 可用{' '} + {formatBytes(disk.freeBytes)} +
    +
  • ))} -
+
{metrics.gpu && metrics.gpu.length > 0 && metrics.gpu[0].status !== 'not_available' && (
-

GPU

+

图形设备

{metrics.gpu.map((gpu, index) => (
{gpu.name} {gpu.status}
-
-
利用率
-
-
-
-
{formatPercent(gpu.utilizationPercent)}
-
+
+ 利用率 + {formatPercent(gpu.utilizationPercent)} +
显存总量 {gpu.memoryTotalMB} MB @@ -336,167 +365,150 @@ export const ServerDetail = ({ metrics, serverName, onClose }: ServerDetailProps {metrics.network && metrics.network.length > 0 && (
-

网络接口

-
+

网络

+
+
+ 接口 + IP + MAC + 接收 + 发送 +
{metrics.network.map((iface, index) => ( -
-
-
-
- {iface.name} - {iface.ipAddress && ( - - {iface.ipAddress} - - )} -
-
-
-
- {(iface.macAddress || iface.ipAddress) && ( -
- {iface.macAddress && ( -
- MAC 地址 - {iface.macAddress} -
- )} - {iface.ipAddress && ( -
- IP 地址 - {iface.ipAddress} -
- )} -
- )} -
-
-
- 接收流量 - {formatBytes(iface.rxBytes)} -
-
-
-
- 发送流量 - {formatBytes(iface.txBytes)} -
-
-
-
+
+ + {iface.name} + + + {iface.ipAddress && iface.ipAddress !== 'N/A' ? iface.ipAddress : '—'} + + + {iface.macAddress || '—'} + + + {formatBytes(iface.rxBytes)} + + + {formatBytes(iface.txBytes)} +
))}
)} - {metrics.system.topProcesses && metrics.system.topProcesses.length > 0 && ( + {((metrics.system.topProcesses && metrics.system.topProcesses.length > 0) || + (metrics.system.dockerStats && metrics.system.dockerStats.available)) && (
-

Top 5 进程

-
- {metrics.system.topProcesses.map((proc, index) => ( -
-
{index + 1}
-
-
-
- {proc.name} - PID: {proc.pid} -
+

进程与服务

+ {metrics.system.topProcesses && metrics.system.topProcesses.length > 0 && ( +
+

资源占用较高的进程(前 10)

+
+
+ # + 进程 + PID + CPU + 内存 +
+ {metrics.system.topProcesses.map((proc, index) => ( +
+ + {index + 1} + + + {proc.name} + + + {proc.pid} + + + {proc.cpu}% + + + {proc.memory}% + {proc.memoryMB.toFixed(1)} MB +
-
-
-
- CPU - {proc.cpu}% -
-
-
-
-
-
-
- 内存 - {proc.memory}% ({proc.memoryMB.toFixed(1)} MB) -
-
-
-
-
+ ))} +
+
+ )} + + {metrics.system.dockerStats && metrics.system.dockerStats.available && ( +
+

Docker

+ {metrics.system.dockerStats.version && ( +
+
+ Docker 版本 + {metrics.system.dockerStats.version} +
+
+ 运行中的容器 + {metrics.system.dockerStats.running} +
+
+ 停止的容器 + {metrics.system.dockerStats.stopped} +
+
+ 镜像数量 + {metrics.system.dockerStats.imageCount}
-
- ))} -
-
- )} + )} - {metrics.system.dockerStats && metrics.system.dockerStats.available && ( -
-

Docker 信息

- {metrics.system.dockerStats.version && ( -
-
- Docker 版本 - {metrics.system.dockerStats.version} -
-
- 运行中的容器 - {metrics.system.dockerStats.running} -
-
- 停止的容器 - {metrics.system.dockerStats.stopped} -
-
- 镜像数量 - {metrics.system.dockerStats.imageCount} -
-
- )} - - {metrics.system.dockerStats.runningNames && metrics.system.dockerStats.runningNames.length > 0 && ( -
-

运行中的容器

-
- {metrics.system.dockerStats.runningNames.map((name, index) => ( - - {name} - - ))} -
-
- )} - - {metrics.system.dockerStats.stoppedNames && metrics.system.dockerStats.stoppedNames.length > 0 && ( -
-

停止的容器

-
- {metrics.system.dockerStats.stoppedNames.map((name, index) => ( - - {name} - - ))} -
-
- )} - - {metrics.system.dockerStats.imageNames && metrics.system.dockerStats.imageNames.length > 0 && ( -
-

镜像列表

-
- {metrics.system.dockerStats.imageNames.map((name, index) => ( - - {name} - - ))} -
+ {metrics.system.dockerStats.runningNames && metrics.system.dockerStats.runningNames.length > 0 && ( +
+

运行中的容器

+
+ {metrics.system.dockerStats.runningNames.map((name, index) => ( + + {name} + + ))} +
+
+ )} + + {metrics.system.dockerStats.stoppedNames && metrics.system.dockerStats.stoppedNames.length > 0 && ( +
+

停止的容器

+
+ {metrics.system.dockerStats.stoppedNames.map((name, index) => ( + + {name} + + ))} +
+
+ )} + + {metrics.system.dockerStats.imageNames && metrics.system.dockerStats.imageNames.length > 0 && ( +
+

镜像列表

+
+ {metrics.system.dockerStats.imageNames.map((name, index) => ( + + {name} + + ))} +
+
+ )}
)}
@@ -504,7 +516,8 @@ export const ServerDetail = ({ metrics, serverName, onClose }: ServerDetailProps {metrics.system.systemLogs && metrics.system.systemLogs.length > 0 && (
-

系统日志

+

诊断与日志

+

系统日志

{metrics.system.systemLogs.map((log, index) => (
diff --git a/mengyamonitor-frontend/src/hooks/usePublicServers.ts b/mengyamonitor-frontend/src/hooks/usePublicServers.ts new file mode 100644 index 0000000..bb2506e --- /dev/null +++ b/mengyamonitor-frontend/src/hooks/usePublicServers.ts @@ -0,0 +1,37 @@ +import { useState, useEffect, useCallback } from 'react'; +import type { ServerConfig } from '../types'; +import { fetchPublicServers } from '../api/central'; + +export const usePublicServers = (refreshMs: number = 30000) => { + const [servers, setServers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const reload = useCallback(async () => { + try { + setError(null); + const list = await fetchPublicServers(); + setServers( + list.map((s) => ({ + id: s.id, + name: s.name, + url: s.url.replace(/\/$/, ''), + enabled: s.enabled, + })) + ); + } catch (e) { + setError(e instanceof Error ? e.message : '加载失败'); + setServers([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void reload(); + const t = setInterval(() => void reload(), refreshMs); + return () => clearInterval(t); + }, [reload, refreshMs]); + + return { servers, loading, error, reload }; +}; diff --git a/mengyamonitor-frontend/src/hooks/useServerMonitor.ts b/mengyamonitor-frontend/src/hooks/useServerMonitor.ts index 1105267..f527bc2 100644 --- a/mengyamonitor-frontend/src/hooks/useServerMonitor.ts +++ b/mengyamonitor-frontend/src/hooks/useServerMonitor.ts @@ -1,58 +1,107 @@ -import { useState, useEffect, useCallback } from 'react'; -import type { ServerConfig, ServerStatus } from '../types'; -import { fetchServerMetrics } from '../api/monitor'; +import { useState, useEffect } from 'react'; +import type { ServerStatus } from '../types'; +import { centralApiBase } from '../api/central'; -export const useServerMonitor = (servers: ServerConfig[], interval: number = 2000) => { +function monitorWsURL(): string { + const base = centralApiBase(); + if (base.startsWith('http://') || base.startsWith('https://')) { + const u = new URL(base); + u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:'; + u.pathname = (u.pathname.replace(/\/$/, '') + '/api/ws/monitor').replace(/\/+/g, '/'); + if (!u.pathname.startsWith('/')) u.pathname = '/' + u.pathname; + return u.toString(); + } + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const path = `${base.replace(/\/$/, '')}/api/ws/monitor`.replace(/\/+/g, '/'); + return `${proto}//${window.location.host}${path.startsWith('/') ? path : '/' + path}`; +} + +function detachWebSocket(ws: WebSocket) { + ws.onmessage = null; + ws.onclose = null; + ws.onerror = null; + ws.onopen = null; +} + +/** Live metrics from the central WebSocket snapshot (gRPC-backed agents only). */ +export const useServerMonitor = () => { const [statuses, setStatuses] = useState>({}); - const fetchMetrics = useCallback(async (server: ServerConfig) => { - if (!server.enabled) { - return; - } - - try { - const metrics = await fetchServerMetrics(server.url); - setStatuses(prev => ({ - ...prev, - [server.id]: { - serverId: server.id, - online: true, - metrics, - lastUpdate: Date.now(), - }, - })); - } catch (error) { - setStatuses(prev => ({ - ...prev, - [server.id]: { - serverId: server.id, - online: false, - error: error instanceof Error ? error.message : 'Unknown error', - lastUpdate: Date.now(), - }, - })); - } - }, []); - useEffect(() => { - // Initial fetch - servers.forEach(server => { - if (server.enabled) { - fetchMetrics(server); + let cancelled = false; + let pendingTimer: ReturnType | undefined; + let socket: WebSocket | null = null; + const url = monitorWsURL(); + + const clearPending = () => { + if (pendingTimer !== undefined) { + clearTimeout(pendingTimer); + pendingTimer = undefined; } - }); + }; - // Set up polling - const timer = setInterval(() => { - servers.forEach(server => { - if (server.enabled) { - fetchMetrics(server); + const scheduleConnect = (delayMs: number) => { + clearPending(); + pendingTimer = window.setTimeout(() => { + pendingTimer = undefined; + if (!cancelled) openSocket(); + }, delayMs); + }; + + const openSocket = () => { + if (cancelled) return; + clearPending(); + try { + socket = new WebSocket(url); + } catch { + if (!cancelled) scheduleConnect(2000); + return; + } + + const ws = socket; + ws.onmessage = (ev) => { + try { + const msg = JSON.parse(ev.data as string) as { + type?: string; + data?: Record; + }; + if (msg.type === 'snapshot' && msg.data && typeof msg.data === 'object') { + setStatuses((prev) => { + const next = { ...prev }; + for (const [id, st] of Object.entries(msg.data!)) { + if (st && typeof st === 'object' && 'serverId' in st) { + next[id] = st as ServerStatus; + } + } + return next; + }); + } + } catch { + /* ignore bad frame */ } - }); - }, interval); + }; - return () => clearInterval(timer); - }, [servers, interval, fetchMetrics]); + ws.onclose = () => { + socket = null; + if (cancelled) return; + scheduleConnect(2000); + }; + }; + + // 推迟到下个 macrotask,避免 React StrictMode 开发环境下首次 effect 立即卸载时 + // 在 CONNECTING 阶段 close() 触发浏览器 “closed before connection is established” 噪声。 + scheduleConnect(0); + + return () => { + cancelled = true; + clearPending(); + if (socket) { + detachWebSocket(socket); + socket.close(); + socket = null; + } + }; + }, []); return statuses; }; diff --git a/mengyamonitor-frontend/src/index.css b/mengyamonitor-frontend/src/index.css index 7e46452..9a00521 100644 --- a/mengyamonitor-frontend/src/index.css +++ b/mengyamonitor-frontend/src/index.css @@ -13,42 +13,49 @@ } :root { - /* Theme Colors - 淡绿色/淡黄绿色渐变 */ - --primary-color: #86efac; - --primary-hover: #6ee7b7; - --secondary-color: #a7f3d0; + /* Pastel / glass accents(参考玻璃拟态面板) */ + --primary-color: #7dd3fc; + --primary-hover: #38bdf8; + --secondary-color: #c4b5fd; + --accent-rose: #f9a8d4; --warning-color: #fde047; --danger-color: #f87171; - --success-color: #86efac; - - /* Backgrounds - 纯白色 */ - --bg-color: #ffffff; - --surface-color: #ffffff; - --surface-hover: #ffffff; - + --success-color: #6ee7b7; + + /* Glass surfaces(--bg-color 用于表单、凹槽等需可读底色的区域) */ + --bg-color: rgba(255, 255, 255, 0.72); + --surface-color: rgba(255, 255, 255, 0.52); + --surface-hover: rgba(255, 255, 255, 0.64); + --glass-border: rgba(255, 255, 255, 0.45); + --glass-shadow: 0 8px 32px rgba(99, 102, 241, 0.08), 0 2px 12px rgba(15, 23, 42, 0.06); + --backdrop-blur: blur(12px); + /* Text */ - --text-main: #0f172a; + --text-main: #1e293b; --text-secondary: #64748b; --text-light: #94a3b8; - - /* Borders & Shadows - 灰白色边框 */ - --border-color: #e2e8f0; - --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); - --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); - --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); - - /* Radius */ - --radius-sm: 0.375rem; - --radius-md: 0.5rem; - --radius-lg: 1rem; - - font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + + --border-color: rgba(255, 255, 255, 0.55); + --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06); + --shadow-md: var(--glass-shadow); + --shadow-lg: 0 16px 48px rgba(99, 102, 241, 0.12), 0 4px 16px rgba(15, 23, 42, 0.08); + + --radius-sm: 0.25rem; + --radius-md: 0.375rem; + --radius-lg: 0.5rem; + --radius-glass: 0.625rem; + + --font-ui: + system-ui, -apple-system, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', sans-serif; + + font-family: var(--font-ui); + font-size: 100%; line-height: 1.5; font-weight: 400; color-scheme: light; color: var(--text-main); - background-color: var(--bg-color); + background-color: transparent; font-synthesis: none; text-rendering: optimizeLegibility; @@ -60,7 +67,13 @@ body { margin: 0; min-width: 320px; min-height: 100vh; - background-color: var(--bg-color); + background-color: rgba(250, 245, 255, 0.4); +} + +#root { + position: relative; + z-index: 1; + min-height: 100vh; } h1, h2, h3, h4, h5, h6 { diff --git a/mengyamonitor-frontend/src/types/index.ts b/mengyamonitor-frontend/src/types/index.ts index 1bd6af4..3c1e2f7 100644 --- a/mengyamonitor-frontend/src/types/index.ts +++ b/mengyamonitor-frontend/src/types/index.ts @@ -24,6 +24,13 @@ export interface MemoryMetrics { usedBytes: number; freeBytes: number; usedPercent: number; + /** 交换分区 / swap 总量(字节),由新版采集端上报 */ + swapTotalBytes?: number; + swapUsedBytes?: number; + swapFreeBytes?: number; + swapUsedPercent?: number; + /** file / partition / zram 等,来自 /proc/swaps;「无」表示未配置 swap */ + swapType?: string; } export interface StorageMetrics { @@ -55,6 +62,10 @@ export interface NetworkInterface { export interface SystemStats { processCount: number; + /** 当前登录会话数(who 行数);新版采集端上报 */ + loggedInUsers?: number; + /** TCP 套接字数(/proc/net/tcp + tcp6,含监听与各状态);新版采集端上报 */ + tcpConnections?: number; packageCount: number; packageManager: string; temperature?: number; diff --git a/mengyamonitor-frontend/src/utils/format.ts b/mengyamonitor-frontend/src/utils/format.ts index 1817dca..5fe1796 100644 --- a/mengyamonitor-frontend/src/utils/format.ts +++ b/mengyamonitor-frontend/src/utils/format.ts @@ -43,3 +43,10 @@ export const formatUptime = (seconds: number): string => { export const formatPercent = (value: number): string => { return `${Math.round(value * 10) / 10}%`; }; + +/** 采集端上报的毫秒延迟(与 Go 侧 TCP 探测展示一致) */ +export const formatLatencyMs = (ms: number): string => { + if (!Number.isFinite(ms) || ms < 0) return '超时'; + if (ms < 1) return `${ms.toFixed(1)} ms`; + return `${Math.round(ms)} ms`; +}; diff --git a/mengyamonitor-frontend/vite.config.ts b/mengyamonitor-frontend/vite.config.ts index b59b8f6..9f798a4 100644 --- a/mengyamonitor-frontend/vite.config.ts +++ b/mengyamonitor-frontend/vite.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ react(), VitePWA({ registerType: 'autoUpdate', - includeAssets: ['logo.svg'], + includeAssets: ['logo.png', 'logo2.png', 'favicon.ico'], manifest: { name: '萌芽监控面板', short_name: '萌芽监控', @@ -20,12 +20,15 @@ export default defineConfig({ scope: '/', start_url: '/', icons: [ - { src: '/logo.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any maskable' } + { src: '/logo.png', sizes: '512x512', type: 'image/png', purpose: 'any' }, + { src: '/logo2.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' }, ], categories: ['utilities', 'productivity'] }, workbox: { globPatterns: ['**/*.{js,css,html,ico,svg,woff2}'], + // 避免开发/生产 SPA 回退误吞 /central-api 转发 + navigateFallbackDenylist: [/^\/central-api(?:\/|$)/], runtimeCaching: [ { urlPattern: /^https:\/\/.*\/api\//i, @@ -47,5 +50,13 @@ export default defineConfig({ server: { port: 2929, host: true, + proxy: { + '/central-api': { + target: 'http://127.0.0.1:9393', + changeOrigin: true, + ws: true, + rewrite: (path) => path.replace(/^\/central-api/, ''), + }, + }, }, }) diff --git a/start-backend.bat b/start-backend.bat index c7d4de8..fa77adf 100644 --- a/start-backend.bat +++ b/start-backend.bat @@ -1,6 +1,6 @@ @echo off chcp 65001 >nul echo 启动后端服务器... -cd mengyadriftbottle-backend +cd mengyamonitor-backend-server go mod tidy go run main.go diff --git a/公网部署/grpc.monitor.api.shumengya.top.conf b/公网部署/grpc.monitor.api.shumengya.top.conf new file mode 100644 index 0000000..70b85ab --- /dev/null +++ b/公网部署/grpc.monitor.api.shumengya.top.conf @@ -0,0 +1,28 @@ +# ============================================================================= +# 萌芽监控 — 采集端 gRPC(TCP 四层透传,与 Gitea SSH 反代同写法) +# ============================================================================= +# 本文件只能被 nginx 主配置里**顶层**的 stream { } 引入,不可放进取 http { } 的 sites 目录。 +# +# 在 /etc/nginx/nginx.conf 中与 http { } 平级增加(路径按你实际放置修改): +# stream { +# include /etc/nginx/stream-enabled/grpc.monitor.api.shumengya.top.conf; +# } +# +# 公网监听端口默认 9394(与中心 GRPC_PORT 一致,采集端好记);若冲突可改为其它端口并放行防火墙。 +# 上游:内网中心宿主机:9394,或 frp/nps 在本机打开的穿透端口(如 127.0.0.1:7001)。 +# +# 采集端(与当前仓库默认 insecure 明文 gRPC 一致): +# CENTRAL_GRPC=公网VPS_IP:9394 +# 若用域名,须 DNS 指向该 VPS(TCP 无 TLS,安全依赖防火墙 / IP 白名单 / 仅穿透不暴露公网)。 +# ============================================================================= + +upstream mengyamonitor_grpc_backend { + server 10.1.1.233:9394; +} + +server { + listen 9394; + proxy_pass mengyamonitor_grpc_backend; + proxy_timeout 1h; + proxy_connect_timeout 10s; +} diff --git a/公网部署/mengyamonitor-frontend.conf b/公网部署/mengyamonitor-frontend.conf new file mode 100644 index 0000000..05e9329 --- /dev/null +++ b/公网部署/mengyamonitor-frontend.conf @@ -0,0 +1,75 @@ +# 内网萌芽监控静态站(示例:6767,公网 monitor.shumengya.top 反代到本机)。 +# root 改为你的 Vite 构建产物目录(npm run build 后的 dist 内容)。 +server +{ + listen 6767; + server_name 192.168.1.100_6767; + index index.html index.htm default.htm default.html; + root /shumengya/www/self-made/mengyamonitor-frontend; + include /www/server/panel/vhost/nginx/extension/192.168.1.100_6767/*.conf; + #CERT-APPLY-CHECK--START + # 用于SSL证书申请时的文件验证相关配置 -- 请勿删除并保持这段设置在优先级高的位置 + include /www/server/panel/vhost/nginx/well-known/192.168.1.100_6767.conf; + #CERT-APPLY-CHECK--END + + #SSL-START SSL相关配置,请勿删除或修改下一行带注释的404规则 + #error_page 404/404.html; + #SSL-END + + #ERROR-PAGE-START 错误页配置,可以注释、删除或修改 + #error_page 404 /404.html; + #error_page 502 /502.html; + #ERROR-PAGE-END + + #REWRITE-START URL重写规则引用,修改后将导致面板设置的伪静态规则失效 + include /www/server/panel/vhost/rewrite/html_192.168.1.100_6767.conf; + #REWRITE-END + + # SPA:浏览器刷新 / 深度链接须回到 index.html;勿删,否则前端路由 404 + location = /index.html { + add_header Cache-Control "no-cache"; + } + + location / { + try_files $uri $uri/ /index.html; + } + + # 禁止访问的敏感文件 + location ~* (\.user.ini|\.htaccess|\.htpasswd|\.env.*|\.project|\.bashrc|\.bash_profile|\.bash_logout|\.DS_Store|\.gitignore|\.gitattributes|LICENSE|README\.md|CLAUDE\.md|CHANGELOG\.md|CHANGELOG|CONTRIBUTING\.md|TODO\.md|FAQ\.md|composer\.json|composer\.lock|package(-lock)?\.json|yarn\.lock|pnpm-lock\.yaml|\.\w+~|\.swp|\.swo|\.bak(up)?|\.old|\.tmp|\.temp|\.log|\.sql(\.gz)?|docker-compose\.yml|docker\.env|Dockerfile|\.csproj|\.sln|Cargo\.toml|Cargo\.lock|go\.mod|go\.sum|phpunit\.xml|phpunit\.xml|pom\.xml|build\.gradl|pyproject\.toml|requirements\.txt|application(-\w+)?\.(ya?ml|properties))$ + { + return 404; + } + + # 禁止访问的敏感目录 + location ~* /(\.git|\.svn|\.bzr|\.vscode|\.claude|\.idea|\.ssh|\.github|\.npm|\.yarn|\.pnpm|\.cache|\.husky|\.turbo|\.next|\.nuxt|node_modules|runtime)/ { + return 404; + } + + #一键申请SSL证书验证目录相关设置 + location ~ \.well-known{ + allow all; + } + + #禁止在证书验证目录放入敏感文件 + if ( $uri ~ "^/\.well-known/.*\.(php|jsp|py|js|css|lua|ts|go|zip|tar\.gz|rar|7z|sql|bak)$" ) { + return 403; + } + + location ~ .*\\.(gif|jpg|jpeg|png|bmp|swf)$ + { + expires 30d; + error_log /dev/null; + access_log /dev/null; + } + + # 带 hash 的前端资源(Vite /assets);短缓存,发版靠文件名变化 + location ~* \.(js|mjs|css|woff2?|svg|ico)$ + { + expires 12h; + add_header Cache-Control "public"; + error_log /dev/null; + access_log /dev/null; + } + access_log /www/wwwlogs/192.168.1.100_6767.log; + error_log /www/wwwlogs/192.168.1.100_6767.error.log; +} \ No newline at end of file diff --git a/公网部署/monitor.api.shumengya.top.conf b/公网部署/monitor.api.shumengya.top.conf new file mode 100644 index 0000000..1c3cda2 --- /dev/null +++ b/公网部署/monitor.api.shumengya.top.conf @@ -0,0 +1,47 @@ +# 萌芽监控 — 浏览器 / 前端对接 API(HTTPS) +# 仅 REST + WebSocket → 9393。采集端 gRPC 为 **stream TCP 透传**,见 grpc.monitor.api.shumengya.top.conf(并入 nginx 顶层 stream{})。 +# 上游 IP 按实际修改(内网中心或可路由到的穿透内网地址)。 + +upstream mengyamonitor_http { + server 10.1.1.233:9393; + keepalive 32; +} + +server { + listen 80; + listen [::]:80; + server_name monitor.api.shumengya.top; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name monitor.api.shumengya.top; + + ssl_certificate /etc/letsencrypt/live/monitor.api.shumengya.top/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/monitor.api.shumengya.top/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + location / { + proxy_pass http://mengyamonitor_http; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + } +} diff --git a/公网部署/monitor.shumengya.top.conf b/公网部署/monitor.shumengya.top.conf new file mode 100644 index 0000000..c33e7a6 --- /dev/null +++ b/公网部署/monitor.shumengya.top.conf @@ -0,0 +1,42 @@ +# 萌芽监控 — 前端 monitor.shumengya.top(HTTPS) +# 内网静态源:按需修改 proxy_pass + +upstream mengyamonitor_frontend_upstream { + server 10.1.1.100:6767; + keepalive 16; +} + +server { + listen 80; + listen [::]:80; + server_name monitor.shumengya.top; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name monitor.shumengya.top; + + ssl_certificate /etc/letsencrypt/live/monitor.shumengya.top/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/monitor.shumengya.top/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + location / { + proxy_pass http://mengyamonitor_frontend_upstream; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + } +} diff --git a/需求.txt b/需求.txt deleted file mode 100644 index 4067185..0000000 --- a/需求.txt +++ /dev/null @@ -1,8 +0,0 @@ -1.实现一个服务器监控面板,目前初步支持Linux服务器,监控服务器基本信息,比如:cpu/GPU,内存,储存,操作系统等 -2.采用企业级前后端分离架构,前端使用React框架,后端使用golang原版自带的net库 -3.原理十分简单,后端go获取Linux相关硬件信息,并封装成相关后端json API路由,前端每隔1秒或者2秒获取后端信息 -4.对外访问使用默认端口:2929 后端默认端口为9292 -5.由于是监控面板 前端可以一次性对接多个服务器后端卡片式展示相关信息,前端面板可以展示一些基本信息,用户可以点击详情按钮查看更多详细信息 -6.前端项目已经初始化 前后端代码架构必须分类整齐符合标准方便我阅读修改 -7.注意前端相当于一个只是客户端用于请求可以配置后端服务器,这两个是分开的,前端可以打包部署在用户电脑上比如做成软件app,而后端构建打包后放到各个服务器上 -8.前端页面风格偏白色柔和风 \ No newline at end of file