summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRikki <i@rikki.moe>2025-04-07 16:11:26 +0800
committerRikki <i@rikki.moe>2025-04-07 16:11:26 +0800
commit06d0e6b4ac65806e95c0f29ab7297416e1898e59 (patch)
tree405590fd3aeed110b6362e905b2c7332fbea93a3
init
-rw-r--r--.github/workflows/release.yaml84
-rw-r--r--.gitignore2
-rw-r--r--command/command.pb.go581
-rw-r--r--command/command_grpc.pb.go197
-rw-r--r--db.go161
-rw-r--r--go.mod35
-rw-r--r--go.sum70
-rw-r--r--main.go120
8 files changed, 1250 insertions, 0 deletions
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
new file mode 100644
index 0000000..1fb6603
--- /dev/null
+++ b/.github/workflows/release.yaml
@@ -0,0 +1,84 @@
+name: Build & Release Go App (CGO Enabled)
+
+on:
+ push:
+ branches: [ main ]
+
+jobs:
+ build:
+ strategy:
+ matrix:
+ include:
+ - arch: amd64
+ runner: ubuntu-latest
+ - arch: arm64
+ runner: ubuntu-24.04-arm # use the label provided by GitHub for native arm64
+ runs-on: ${{ matrix.runner }}
+ env:
+ GOOS: linux
+ CGO_ENABLED: 1
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: '1.22'
+
+ - name: Build binary for ${{ matrix.arch }}
+ run: |
+ echo "Building for GOARCH=${{ matrix.arch }}"
+ go build -v -o v2stat-${{ matrix.arch }} .
+ env:
+ GOARCH: ${{ matrix.arch }}
+
+ - name: Upload artifact for ${{ matrix.arch }}
+ uses: actions/upload-artifact@v3
+ with:
+ name: v2stat-${{ matrix.arch }}
+ path: v2stat-${{ matrix.arch }}
+
+ release:
+ needs: build
+ runs-on: ubuntu-latest
+ if: github.event_name == 'push'
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Create Release
+ id: create_release
+ uses: actions/create-release@v1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ tag_name: v0.0.0-dev${{ github.run_number }}
+ release_name: Release v0.0.0-dev${{ github.run_number }}
+ draft: false
+ prerelease: false
+
+ - name: Download build artifacts
+ uses: actions/download-artifact@v3
+ with:
+ path: artifacts
+
+ - name: Upload amd64 binary to release
+ uses: actions/upload-release-asset@v1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ upload_url: ${{ steps.create_release.outputs.upload_url }}
+ asset_path: artifacts/v2stat-amd64
+ asset_name: v2stat-amd64
+ asset_content_type: application/octet-stream
+
+ - name: Upload arm64 binary to release
+ uses: actions/upload-release-asset@v1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ upload_url: ${{ steps.create_release.outputs.upload_url }}
+ asset_path: artifacts/v2stat-arm64
+ asset_name: v2stat-arm64
+ asset_content_type: application/octet-stream
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e52f51e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/v2stat
+/v2stat.db \ No newline at end of file
diff --git a/command/command.pb.go b/command/command.pb.go
new file mode 100644
index 0000000..0a58d57
--- /dev/null
+++ b/command/command.pb.go
@@ -0,0 +1,581 @@
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// protoc-gen-go v1.36.6
+// protoc v5.29.3
+// source: app/stats/command/command.proto
+
+package command
+
+import (
+ _ "github.com/v2fly/v2ray-core/v5/common/protoext"
+ 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 GetStatsRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // Name of the stat counter.
+ Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+ // Whether or not to reset the counter to fetching its value.
+ Reset_ bool `protobuf:"varint,2,opt,name=reset,proto3" json:"reset,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *GetStatsRequest) Reset() {
+ *x = GetStatsRequest{}
+ mi := &file_app_stats_command_command_proto_msgTypes[0]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *GetStatsRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GetStatsRequest) ProtoMessage() {}
+
+func (x *GetStatsRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_app_stats_command_command_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 GetStatsRequest.ProtoReflect.Descriptor instead.
+func (*GetStatsRequest) Descriptor() ([]byte, []int) {
+ return file_app_stats_command_command_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *GetStatsRequest) GetName() string {
+ if x != nil {
+ return x.Name
+ }
+ return ""
+}
+
+func (x *GetStatsRequest) GetReset_() bool {
+ if x != nil {
+ return x.Reset_
+ }
+ return false
+}
+
+type Stat struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+ Value int64 `protobuf:"varint,2,opt,name=value,proto3" json:"value,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *Stat) Reset() {
+ *x = Stat{}
+ mi := &file_app_stats_command_command_proto_msgTypes[1]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *Stat) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Stat) ProtoMessage() {}
+
+func (x *Stat) ProtoReflect() protoreflect.Message {
+ mi := &file_app_stats_command_command_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 Stat.ProtoReflect.Descriptor instead.
+func (*Stat) Descriptor() ([]byte, []int) {
+ return file_app_stats_command_command_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *Stat) GetName() string {
+ if x != nil {
+ return x.Name
+ }
+ return ""
+}
+
+func (x *Stat) GetValue() int64 {
+ if x != nil {
+ return x.Value
+ }
+ return 0
+}
+
+type GetStatsResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Stat *Stat `protobuf:"bytes,1,opt,name=stat,proto3" json:"stat,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *GetStatsResponse) Reset() {
+ *x = GetStatsResponse{}
+ mi := &file_app_stats_command_command_proto_msgTypes[2]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *GetStatsResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GetStatsResponse) ProtoMessage() {}
+
+func (x *GetStatsResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_app_stats_command_command_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 GetStatsResponse.ProtoReflect.Descriptor instead.
+func (*GetStatsResponse) Descriptor() ([]byte, []int) {
+ return file_app_stats_command_command_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *GetStatsResponse) GetStat() *Stat {
+ if x != nil {
+ return x.Stat
+ }
+ return nil
+}
+
+type QueryStatsRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // Deprecated, use Patterns instead
+ Pattern string `protobuf:"bytes,1,opt,name=pattern,proto3" json:"pattern,omitempty"`
+ Reset_ bool `protobuf:"varint,2,opt,name=reset,proto3" json:"reset,omitempty"`
+ Patterns []string `protobuf:"bytes,3,rep,name=patterns,proto3" json:"patterns,omitempty"`
+ Regexp bool `protobuf:"varint,4,opt,name=regexp,proto3" json:"regexp,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *QueryStatsRequest) Reset() {
+ *x = QueryStatsRequest{}
+ mi := &file_app_stats_command_command_proto_msgTypes[3]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *QueryStatsRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*QueryStatsRequest) ProtoMessage() {}
+
+func (x *QueryStatsRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_app_stats_command_command_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 QueryStatsRequest.ProtoReflect.Descriptor instead.
+func (*QueryStatsRequest) Descriptor() ([]byte, []int) {
+ return file_app_stats_command_command_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *QueryStatsRequest) GetPattern() string {
+ if x != nil {
+ return x.Pattern
+ }
+ return ""
+}
+
+func (x *QueryStatsRequest) GetReset_() bool {
+ if x != nil {
+ return x.Reset_
+ }
+ return false
+}
+
+func (x *QueryStatsRequest) GetPatterns() []string {
+ if x != nil {
+ return x.Patterns
+ }
+ return nil
+}
+
+func (x *QueryStatsRequest) GetRegexp() bool {
+ if x != nil {
+ return x.Regexp
+ }
+ return false
+}
+
+type QueryStatsResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Stat []*Stat `protobuf:"bytes,1,rep,name=stat,proto3" json:"stat,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *QueryStatsResponse) Reset() {
+ *x = QueryStatsResponse{}
+ mi := &file_app_stats_command_command_proto_msgTypes[4]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *QueryStatsResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*QueryStatsResponse) ProtoMessage() {}
+
+func (x *QueryStatsResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_app_stats_command_command_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 QueryStatsResponse.ProtoReflect.Descriptor instead.
+func (*QueryStatsResponse) Descriptor() ([]byte, []int) {
+ return file_app_stats_command_command_proto_rawDescGZIP(), []int{4}
+}
+
+func (x *QueryStatsResponse) GetStat() []*Stat {
+ if x != nil {
+ return x.Stat
+ }
+ return nil
+}
+
+type SysStatsRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *SysStatsRequest) Reset() {
+ *x = SysStatsRequest{}
+ mi := &file_app_stats_command_command_proto_msgTypes[5]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *SysStatsRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*SysStatsRequest) ProtoMessage() {}
+
+func (x *SysStatsRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_app_stats_command_command_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 SysStatsRequest.ProtoReflect.Descriptor instead.
+func (*SysStatsRequest) Descriptor() ([]byte, []int) {
+ return file_app_stats_command_command_proto_rawDescGZIP(), []int{5}
+}
+
+type SysStatsResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ NumGoroutine uint32 `protobuf:"varint,1,opt,name=NumGoroutine,proto3" json:"NumGoroutine,omitempty"`
+ NumGC uint32 `protobuf:"varint,2,opt,name=NumGC,proto3" json:"NumGC,omitempty"`
+ Alloc uint64 `protobuf:"varint,3,opt,name=Alloc,proto3" json:"Alloc,omitempty"`
+ TotalAlloc uint64 `protobuf:"varint,4,opt,name=TotalAlloc,proto3" json:"TotalAlloc,omitempty"`
+ Sys uint64 `protobuf:"varint,5,opt,name=Sys,proto3" json:"Sys,omitempty"`
+ Mallocs uint64 `protobuf:"varint,6,opt,name=Mallocs,proto3" json:"Mallocs,omitempty"`
+ Frees uint64 `protobuf:"varint,7,opt,name=Frees,proto3" json:"Frees,omitempty"`
+ LiveObjects uint64 `protobuf:"varint,8,opt,name=LiveObjects,proto3" json:"LiveObjects,omitempty"`
+ PauseTotalNs uint64 `protobuf:"varint,9,opt,name=PauseTotalNs,proto3" json:"PauseTotalNs,omitempty"`
+ Uptime uint32 `protobuf:"varint,10,opt,name=Uptime,proto3" json:"Uptime,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *SysStatsResponse) Reset() {
+ *x = SysStatsResponse{}
+ mi := &file_app_stats_command_command_proto_msgTypes[6]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *SysStatsResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*SysStatsResponse) ProtoMessage() {}
+
+func (x *SysStatsResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_app_stats_command_command_proto_msgTypes[6]
+ 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 SysStatsResponse.ProtoReflect.Descriptor instead.
+func (*SysStatsResponse) Descriptor() ([]byte, []int) {
+ return file_app_stats_command_command_proto_rawDescGZIP(), []int{6}
+}
+
+func (x *SysStatsResponse) GetNumGoroutine() uint32 {
+ if x != nil {
+ return x.NumGoroutine
+ }
+ return 0
+}
+
+func (x *SysStatsResponse) GetNumGC() uint32 {
+ if x != nil {
+ return x.NumGC
+ }
+ return 0
+}
+
+func (x *SysStatsResponse) GetAlloc() uint64 {
+ if x != nil {
+ return x.Alloc
+ }
+ return 0
+}
+
+func (x *SysStatsResponse) GetTotalAlloc() uint64 {
+ if x != nil {
+ return x.TotalAlloc
+ }
+ return 0
+}
+
+func (x *SysStatsResponse) GetSys() uint64 {
+ if x != nil {
+ return x.Sys
+ }
+ return 0
+}
+
+func (x *SysStatsResponse) GetMallocs() uint64 {
+ if x != nil {
+ return x.Mallocs
+ }
+ return 0
+}
+
+func (x *SysStatsResponse) GetFrees() uint64 {
+ if x != nil {
+ return x.Frees
+ }
+ return 0
+}
+
+func (x *SysStatsResponse) GetLiveObjects() uint64 {
+ if x != nil {
+ return x.LiveObjects
+ }
+ return 0
+}
+
+func (x *SysStatsResponse) GetPauseTotalNs() uint64 {
+ if x != nil {
+ return x.PauseTotalNs
+ }
+ return 0
+}
+
+func (x *SysStatsResponse) GetUptime() uint32 {
+ if x != nil {
+ return x.Uptime
+ }
+ return 0
+}
+
+type Config struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *Config) Reset() {
+ *x = Config{}
+ mi := &file_app_stats_command_command_proto_msgTypes[7]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *Config) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Config) ProtoMessage() {}
+
+func (x *Config) ProtoReflect() protoreflect.Message {
+ mi := &file_app_stats_command_command_proto_msgTypes[7]
+ 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 Config.ProtoReflect.Descriptor instead.
+func (*Config) Descriptor() ([]byte, []int) {
+ return file_app_stats_command_command_proto_rawDescGZIP(), []int{7}
+}
+
+var File_app_stats_command_command_proto protoreflect.FileDescriptor
+
+const file_app_stats_command_command_proto_rawDesc = "" +
+ "\n" +
+ "\x1fapp/stats/command/command.proto\x12\x1cv2ray.core.app.stats.command\x1a common/protoext/extensions.proto\";\n" +
+ "\x0fGetStatsRequest\x12\x12\n" +
+ "\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" +
+ "\x05reset\x18\x02 \x01(\bR\x05reset\"0\n" +
+ "\x04Stat\x12\x12\n" +
+ "\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" +
+ "\x05value\x18\x02 \x01(\x03R\x05value\"J\n" +
+ "\x10GetStatsResponse\x126\n" +
+ "\x04stat\x18\x01 \x01(\v2\".v2ray.core.app.stats.command.StatR\x04stat\"w\n" +
+ "\x11QueryStatsRequest\x12\x18\n" +
+ "\apattern\x18\x01 \x01(\tR\apattern\x12\x14\n" +
+ "\x05reset\x18\x02 \x01(\bR\x05reset\x12\x1a\n" +
+ "\bpatterns\x18\x03 \x03(\tR\bpatterns\x12\x16\n" +
+ "\x06regexp\x18\x04 \x01(\bR\x06regexp\"L\n" +
+ "\x12QueryStatsResponse\x126\n" +
+ "\x04stat\x18\x01 \x03(\v2\".v2ray.core.app.stats.command.StatR\x04stat\"\x11\n" +
+ "\x0fSysStatsRequest\"\xa2\x02\n" +
+ "\x10SysStatsResponse\x12\"\n" +
+ "\fNumGoroutine\x18\x01 \x01(\rR\fNumGoroutine\x12\x14\n" +
+ "\x05NumGC\x18\x02 \x01(\rR\x05NumGC\x12\x14\n" +
+ "\x05Alloc\x18\x03 \x01(\x04R\x05Alloc\x12\x1e\n" +
+ "\n" +
+ "TotalAlloc\x18\x04 \x01(\x04R\n" +
+ "TotalAlloc\x12\x10\n" +
+ "\x03Sys\x18\x05 \x01(\x04R\x03Sys\x12\x18\n" +
+ "\aMallocs\x18\x06 \x01(\x04R\aMallocs\x12\x14\n" +
+ "\x05Frees\x18\a \x01(\x04R\x05Frees\x12 \n" +
+ "\vLiveObjects\x18\b \x01(\x04R\vLiveObjects\x12\"\n" +
+ "\fPauseTotalNs\x18\t \x01(\x04R\fPauseTotalNs\x12\x16\n" +
+ "\x06Uptime\x18\n" +
+ " \x01(\rR\x06Uptime\"\"\n" +
+ "\x06Config:\x18\x82\xb5\x18\x14\n" +
+ "\vgrpcservice\x12\x05stats2\xde\x02\n" +
+ "\fStatsService\x12k\n" +
+ "\bGetStats\x12-.v2ray.core.app.stats.command.GetStatsRequest\x1a..v2ray.core.app.stats.command.GetStatsResponse\"\x00\x12q\n" +
+ "\n" +
+ "QueryStats\x12/.v2ray.core.app.stats.command.QueryStatsRequest\x1a0.v2ray.core.app.stats.command.QueryStatsResponse\"\x00\x12n\n" +
+ "\vGetSysStats\x12-.v2ray.core.app.stats.command.SysStatsRequest\x1a..v2ray.core.app.stats.command.SysStatsResponse\"\x00Bu\n" +
+ " com.v2ray.core.app.stats.commandP\x01Z0github.com/v2fly/v2ray-core/v5/app/stats/command\xaa\x02\x1cV2Ray.Core.App.Stats.Commandb\x06proto3"
+
+var (
+ file_app_stats_command_command_proto_rawDescOnce sync.Once
+ file_app_stats_command_command_proto_rawDescData []byte
+)
+
+func file_app_stats_command_command_proto_rawDescGZIP() []byte {
+ file_app_stats_command_command_proto_rawDescOnce.Do(func() {
+ file_app_stats_command_command_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_app_stats_command_command_proto_rawDesc), len(file_app_stats_command_command_proto_rawDesc)))
+ })
+ return file_app_stats_command_command_proto_rawDescData
+}
+
+var file_app_stats_command_command_proto_msgTypes = make([]protoimpl.MessageInfo, 8)
+var file_app_stats_command_command_proto_goTypes = []any{
+ (*GetStatsRequest)(nil), // 0: v2ray.core.app.stats.command.GetStatsRequest
+ (*Stat)(nil), // 1: v2ray.core.app.stats.command.Stat
+ (*GetStatsResponse)(nil), // 2: v2ray.core.app.stats.command.GetStatsResponse
+ (*QueryStatsRequest)(nil), // 3: v2ray.core.app.stats.command.QueryStatsRequest
+ (*QueryStatsResponse)(nil), // 4: v2ray.core.app.stats.command.QueryStatsResponse
+ (*SysStatsRequest)(nil), // 5: v2ray.core.app.stats.command.SysStatsRequest
+ (*SysStatsResponse)(nil), // 6: v2ray.core.app.stats.command.SysStatsResponse
+ (*Config)(nil), // 7: v2ray.core.app.stats.command.Config
+}
+var file_app_stats_command_command_proto_depIdxs = []int32{
+ 1, // 0: v2ray.core.app.stats.command.GetStatsResponse.stat:type_name -> v2ray.core.app.stats.command.Stat
+ 1, // 1: v2ray.core.app.stats.command.QueryStatsResponse.stat:type_name -> v2ray.core.app.stats.command.Stat
+ 0, // 2: v2ray.core.app.stats.command.StatsService.GetStats:input_type -> v2ray.core.app.stats.command.GetStatsRequest
+ 3, // 3: v2ray.core.app.stats.command.StatsService.QueryStats:input_type -> v2ray.core.app.stats.command.QueryStatsRequest
+ 5, // 4: v2ray.core.app.stats.command.StatsService.GetSysStats:input_type -> v2ray.core.app.stats.command.SysStatsRequest
+ 2, // 5: v2ray.core.app.stats.command.StatsService.GetStats:output_type -> v2ray.core.app.stats.command.GetStatsResponse
+ 4, // 6: v2ray.core.app.stats.command.StatsService.QueryStats:output_type -> v2ray.core.app.stats.command.QueryStatsResponse
+ 6, // 7: v2ray.core.app.stats.command.StatsService.GetSysStats:output_type -> v2ray.core.app.stats.command.SysStatsResponse
+ 5, // [5:8] is the sub-list for method output_type
+ 2, // [2:5] is the sub-list for method input_type
+ 2, // [2:2] is the sub-list for extension type_name
+ 2, // [2:2] is the sub-list for extension extendee
+ 0, // [0:2] is the sub-list for field type_name
+}
+
+func init() { file_app_stats_command_command_proto_init() }
+func file_app_stats_command_command_proto_init() {
+ if File_app_stats_command_command_proto != nil {
+ return
+ }
+ type x struct{}
+ out := protoimpl.TypeBuilder{
+ File: protoimpl.DescBuilder{
+ GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+ RawDescriptor: unsafe.Slice(unsafe.StringData(file_app_stats_command_command_proto_rawDesc), len(file_app_stats_command_command_proto_rawDesc)),
+ NumEnums: 0,
+ NumMessages: 8,
+ NumExtensions: 0,
+ NumServices: 1,
+ },
+ GoTypes: file_app_stats_command_command_proto_goTypes,
+ DependencyIndexes: file_app_stats_command_command_proto_depIdxs,
+ MessageInfos: file_app_stats_command_command_proto_msgTypes,
+ }.Build()
+ File_app_stats_command_command_proto = out.File
+ file_app_stats_command_command_proto_goTypes = nil
+ file_app_stats_command_command_proto_depIdxs = nil
+}
diff --git a/command/command_grpc.pb.go b/command/command_grpc.pb.go
new file mode 100644
index 0000000..112e4fa
--- /dev/null
+++ b/command/command_grpc.pb.go
@@ -0,0 +1,197 @@
+// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
+// versions:
+// - protoc-gen-go-grpc v1.5.1
+// - protoc v5.29.3
+// source: app/stats/command/command.proto
+
+package command
+
+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 (
+ StatsService_GetStats_FullMethodName = "/v2ray.core.app.stats.command.StatsService/GetStats"
+ StatsService_QueryStats_FullMethodName = "/v2ray.core.app.stats.command.StatsService/QueryStats"
+ StatsService_GetSysStats_FullMethodName = "/v2ray.core.app.stats.command.StatsService/GetSysStats"
+)
+
+// StatsServiceClient is the client API for StatsService 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.
+type StatsServiceClient interface {
+ GetStats(ctx context.Context, in *GetStatsRequest, opts ...grpc.CallOption) (*GetStatsResponse, error)
+ QueryStats(ctx context.Context, in *QueryStatsRequest, opts ...grpc.CallOption) (*QueryStatsResponse, error)
+ GetSysStats(ctx context.Context, in *SysStatsRequest, opts ...grpc.CallOption) (*SysStatsResponse, error)
+}
+
+type statsServiceClient struct {
+ cc grpc.ClientConnInterface
+}
+
+func NewStatsServiceClient(cc grpc.ClientConnInterface) StatsServiceClient {
+ return &statsServiceClient{cc}
+}
+
+func (c *statsServiceClient) GetStats(ctx context.Context, in *GetStatsRequest, opts ...grpc.CallOption) (*GetStatsResponse, error) {
+ cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+ out := new(GetStatsResponse)
+ err := c.cc.Invoke(ctx, StatsService_GetStats_FullMethodName, in, out, cOpts...)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+func (c *statsServiceClient) QueryStats(ctx context.Context, in *QueryStatsRequest, opts ...grpc.CallOption) (*QueryStatsResponse, error) {
+ cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+ out := new(QueryStatsResponse)
+ err := c.cc.Invoke(ctx, StatsService_QueryStats_FullMethodName, in, out, cOpts...)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+func (c *statsServiceClient) GetSysStats(ctx context.Context, in *SysStatsRequest, opts ...grpc.CallOption) (*SysStatsResponse, error) {
+ cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+ out := new(SysStatsResponse)
+ err := c.cc.Invoke(ctx, StatsService_GetSysStats_FullMethodName, in, out, cOpts...)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+// StatsServiceServer is the server API for StatsService service.
+// All implementations must embed UnimplementedStatsServiceServer
+// for forward compatibility.
+type StatsServiceServer interface {
+ GetStats(context.Context, *GetStatsRequest) (*GetStatsResponse, error)
+ QueryStats(context.Context, *QueryStatsRequest) (*QueryStatsResponse, error)
+ GetSysStats(context.Context, *SysStatsRequest) (*SysStatsResponse, error)
+ mustEmbedUnimplementedStatsServiceServer()
+}
+
+// UnimplementedStatsServiceServer 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 UnimplementedStatsServiceServer struct{}
+
+func (UnimplementedStatsServiceServer) GetStats(context.Context, *GetStatsRequest) (*GetStatsResponse, error) {
+ return nil, status.Errorf(codes.Unimplemented, "method GetStats not implemented")
+}
+func (UnimplementedStatsServiceServer) QueryStats(context.Context, *QueryStatsRequest) (*QueryStatsResponse, error) {
+ return nil, status.Errorf(codes.Unimplemented, "method QueryStats not implemented")
+}
+func (UnimplementedStatsServiceServer) GetSysStats(context.Context, *SysStatsRequest) (*SysStatsResponse, error) {
+ return nil, status.Errorf(codes.Unimplemented, "method GetSysStats not implemented")
+}
+func (UnimplementedStatsServiceServer) mustEmbedUnimplementedStatsServiceServer() {}
+func (UnimplementedStatsServiceServer) testEmbeddedByValue() {}
+
+// UnsafeStatsServiceServer may be embedded to opt out of forward compatibility for this service.
+// Use of this interface is not recommended, as added methods to StatsServiceServer will
+// result in compilation errors.
+type UnsafeStatsServiceServer interface {
+ mustEmbedUnimplementedStatsServiceServer()
+}
+
+func RegisterStatsServiceServer(s grpc.ServiceRegistrar, srv StatsServiceServer) {
+ // If the following call pancis, it indicates UnimplementedStatsServiceServer 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(&StatsService_ServiceDesc, srv)
+}
+
+func _StatsService_GetStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+ in := new(GetStatsRequest)
+ if err := dec(in); err != nil {
+ return nil, err
+ }
+ if interceptor == nil {
+ return srv.(StatsServiceServer).GetStats(ctx, in)
+ }
+ info := &grpc.UnaryServerInfo{
+ Server: srv,
+ FullMethod: StatsService_GetStats_FullMethodName,
+ }
+ handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+ return srv.(StatsServiceServer).GetStats(ctx, req.(*GetStatsRequest))
+ }
+ return interceptor(ctx, in, info, handler)
+}
+
+func _StatsService_QueryStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+ in := new(QueryStatsRequest)
+ if err := dec(in); err != nil {
+ return nil, err
+ }
+ if interceptor == nil {
+ return srv.(StatsServiceServer).QueryStats(ctx, in)
+ }
+ info := &grpc.UnaryServerInfo{
+ Server: srv,
+ FullMethod: StatsService_QueryStats_FullMethodName,
+ }
+ handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+ return srv.(StatsServiceServer).QueryStats(ctx, req.(*QueryStatsRequest))
+ }
+ return interceptor(ctx, in, info, handler)
+}
+
+func _StatsService_GetSysStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+ in := new(SysStatsRequest)
+ if err := dec(in); err != nil {
+ return nil, err
+ }
+ if interceptor == nil {
+ return srv.(StatsServiceServer).GetSysStats(ctx, in)
+ }
+ info := &grpc.UnaryServerInfo{
+ Server: srv,
+ FullMethod: StatsService_GetSysStats_FullMethodName,
+ }
+ handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+ return srv.(StatsServiceServer).GetSysStats(ctx, req.(*SysStatsRequest))
+ }
+ return interceptor(ctx, in, info, handler)
+}
+
+// StatsService_ServiceDesc is the grpc.ServiceDesc for StatsService service.
+// It's only intended for direct use with grpc.RegisterService,
+// and not to be introspected or modified (even as a copy)
+var StatsService_ServiceDesc = grpc.ServiceDesc{
+ ServiceName: "v2ray.core.app.stats.command.StatsService",
+ HandlerType: (*StatsServiceServer)(nil),
+ Methods: []grpc.MethodDesc{
+ {
+ MethodName: "GetStats",
+ Handler: _StatsService_GetStats_Handler,
+ },
+ {
+ MethodName: "QueryStats",
+ Handler: _StatsService_QueryStats_Handler,
+ },
+ {
+ MethodName: "GetSysStats",
+ Handler: _StatsService_GetSysStats_Handler,
+ },
+ },
+ Streams: []grpc.StreamDesc{},
+ Metadata: "app/stats/command/command.proto",
+}
diff --git a/db.go b/db.go
new file mode 100644
index 0000000..86cedb3
--- /dev/null
+++ b/db.go
@@ -0,0 +1,161 @@
+package main
+
+import (
+ "context"
+ "strings"
+ "time"
+
+ "go.rikki.moe/v2stat/command"
+)
+
+const (
+ DirectionDownlink = iota
+ DirectionUplink
+)
+
+const (
+ ConnTypeUser = iota
+ ConnTypeInbound
+ ConnTypeOutbound
+)
+
+type ConnInfo struct {
+ Type int `json:"type"`
+ Name string `json:"name"`
+}
+
+func (v *V2Stat) InitDB() error {
+ stmts := []string{
+ `CREATE TABLE IF NOT EXISTS conn (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ type INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ UNIQUE (type, name)
+ );`,
+ `CREATE TABLE IF NOT EXISTS stats (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ conn_id INTEGER NOT NULL,
+ timestamp INTEGER NOT NULL,
+ traffic INTEGER NOT NULL,
+ direction INTEGER NOT NULL,
+ FOREIGN KEY (conn_id) REFERENCES conn (id)
+ ON DELETE CASCADE
+ ON UPDATE CASCADE
+ );`,
+ `CREATE INDEX IF NOT EXISTS idx_conn_id ON stats (conn_id);`,
+ `CREATE INDEX IF NOT EXISTS idx_timestamp ON stats (timestamp);`,
+ }
+
+ for _, stmt := range stmts {
+ if _, err := v.db.Exec(stmt); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (v *V2Stat) RecordNow(ctx context.Context) error {
+ resp, err := v.stat.QueryStats(ctx, &command.QueryStatsRequest{
+ Reset_: true,
+ })
+ if err != nil {
+ v.logger.Errorf("Failed to query stats: %v", err)
+ return err
+ }
+
+ tx, err := v.db.Begin()
+ if err != nil {
+ v.logger.Errorf("Failed to begin transaction: %v", err)
+ return err
+ }
+ defer tx.Rollback() // safe to call even if already committed
+
+ insertConnStmt, err := tx.Prepare(`INSERT OR IGNORE INTO conn (type, name) VALUES (?, ?)`)
+ if err != nil {
+ v.logger.Errorf("Failed to prepare conn insert statement: %v", err)
+ return err
+ }
+ defer insertConnStmt.Close()
+
+ selectConnIDStmt, err := tx.Prepare(`SELECT id FROM conn WHERE type = ? AND name = ?`)
+ if err != nil {
+ v.logger.Errorf("Failed to prepare conn select statement: %v", err)
+ return err
+ }
+ defer selectConnIDStmt.Close()
+
+ insertStatsStmt, err := tx.Prepare(`
+ INSERT INTO stats (conn_id, timestamp, traffic, direction)
+ VALUES (?, ?, ?, ?)
+ `)
+ if err != nil {
+ v.logger.Errorf("Failed to prepare stats insert statement: %v", err)
+ return err
+ }
+ defer insertStatsStmt.Close()
+
+ for _, stat := range resp.Stat {
+ connType, connName, direction, ok := parseStatKey(stat.Name)
+ if !ok {
+ v.logger.Warnf("Skipping unrecognized stat key: %s", stat.Name)
+ continue
+ }
+
+ if _, err := insertConnStmt.Exec(connType, connName); err != nil {
+ v.logger.Errorf("Failed to insert conn: %v", err)
+ continue
+ }
+
+ var connID int
+ err = selectConnIDStmt.QueryRow(connType, connName).Scan(&connID)
+ if err != nil {
+ v.logger.Errorf("Failed to retrieve conn_id: %v", err)
+ continue
+ }
+
+ timeNow := time.Now().Unix()
+ if _, err := insertStatsStmt.Exec(connID, timeNow, stat.Value, direction); err != nil {
+ v.logger.Errorf("Failed to insert stats: %v", err)
+ continue
+ }
+
+ v.logger.Infof("Inserted stats: conn_id=%d, timestamp=%d, traffic=%d, direction=%d", connID, timeNow, stat.Value, direction)
+ }
+
+ if err := tx.Commit(); err != nil {
+ v.logger.Errorf("Failed to commit transaction: %v", err)
+ return err
+ }
+ return nil
+}
+
+func parseStatKey(key string) (connType int, connName string, direction int, ok bool) {
+ parts := strings.Split(key, ">>>")
+ if len(parts) != 4 || parts[2] != "traffic" {
+ return 0, "", 0, false
+ }
+
+ switch parts[0] {
+ case "user":
+ connType = ConnTypeUser
+ case "inbound":
+ connType = ConnTypeInbound
+ case "outbound":
+ connType = ConnTypeOutbound
+ default:
+ return 0, "", 0, false
+ }
+
+ connName = parts[1]
+
+ switch parts[3] {
+ case "downlink":
+ direction = DirectionDownlink
+ case "uplink":
+ direction = DirectionUplink
+ default:
+ return 0, "", 0, false
+ }
+
+ return connType, connName, direction, true
+} \ No newline at end of file
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..f436445
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,35 @@
+module go.rikki.moe/v2stat
+
+go 1.24.2
+
+require google.golang.org/grpc v1.71.1
+
+require (
+ github.com/adrg/xdg v0.5.3 // indirect
+ github.com/andybalholm/brotli v1.1.0 // indirect
+ github.com/gofiber/fiber/v2 v2.52.6 // indirect
+ github.com/gofiber/template v1.8.3 // indirect
+ github.com/gofiber/template/html/v2 v2.1.3 // indirect
+ github.com/gofiber/utils v1.1.0 // indirect
+ github.com/golang/protobuf v1.5.4 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/klauspost/compress v1.17.9 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-runewidth v0.0.16 // indirect
+ github.com/mattn/go-sqlite3 v1.14.27 // indirect
+ github.com/rivo/uniseg v0.2.0 // indirect
+ github.com/valyala/bytebufferpool v1.0.0 // indirect
+ github.com/valyala/fasthttp v1.51.0 // indirect
+ github.com/valyala/tcplisten v1.0.0 // indirect
+)
+
+require (
+ github.com/sirupsen/logrus v1.9.3 // indirect
+ github.com/v2fly/v2ray-core/v5 v5.30.0
+ golang.org/x/net v0.38.0 // indirect
+ golang.org/x/sys v0.31.0 // indirect
+ golang.org/x/text v0.23.0 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f // indirect
+ google.golang.org/protobuf v1.36.5 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..770e563
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,70 @@
+github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
+github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
+github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
+github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI=
+github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
+github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc=
+github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8=
+github.com/gofiber/template/html/v2 v2.1.3 h1:n1LYBtmr9C0V/k/3qBblXyMxV5B0o/gpb6dFLp8ea+o=
+github.com/gofiber/template/html/v2 v2.1.3/go.mod h1:U5Fxgc5KpyujU9OqKzy6Kn6Qup6Tm7zdsISR+VpnHRE=
+github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM=
+github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
+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/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
+github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+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/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
+github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mattn/go-sqlite3 v1.14.27 h1:drZCnuvf37yPfs95E5jd9s3XhdVWLal+6BOK6qrv6IU=
+github.com/mattn/go-sqlite3 v1.14.27/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/v2fly/v2ray-core/v5 v5.30.0 h1:lVREGQjNCSQ38PpMTGWRKav47pd/XT1x+Ig3fUXYW8A=
+github.com/v2fly/v2ray-core/v5 v5.30.0/go.mod h1:qv4cRgZcZaYv5IWiCULK4KBR7utwbh302w02Py1Sb5g=
+github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
+github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
+github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
+github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
+github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
+github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
+golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
+golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
+golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
+golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
+golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
+golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+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/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
+golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:OxYkA3wjPsZyBylwymxSHa7ViiW1Sml4ToBrncvFehI=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50=
+google.golang.org/grpc v1.71.1 h1:ffsFWr7ygTUscGPI0KKK6TLrGz0476KUvvsbqWK0rPI=
+google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
+google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM=
+google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
+google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
+google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
+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=
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..95efa23
--- /dev/null
+++ b/main.go
@@ -0,0 +1,120 @@
+package main
+
+import (
+ "context"
+ "database/sql"
+ "flag"
+ "os"
+ "os/signal"
+ "time"
+
+ _ "github.com/mattn/go-sqlite3"
+ "github.com/sirupsen/logrus"
+ "google.golang.org/grpc"
+
+ "go.rikki.moe/v2stat/command"
+)
+
+var (
+ flagDatabase = flag.String("db", "v2stat.db", "Path to SQLite database")
+ flagServer = flag.String("server", "127.0.0.1:8080", "V2Ray API server address")
+ flagLogLevel = flag.String("log-level", "info", "Log level (debug, info, warn, error, fatal, panic)")
+)
+
+// V2Stat holds references to the logger, database connection, and gRPC client.
+type V2Stat struct {
+ logger *logrus.Logger
+ db *sql.DB
+ stat command.StatsServiceClient
+}
+
+func main() {
+ flag.Parse()
+
+ // Initialize logger
+ level, err := logrus.ParseLevel(*flagLogLevel)
+ if err != nil {
+ logrus.Fatalf("Invalid log level: %v", err)
+ }
+ logger := logrus.New()
+ logger.SetLevel(level)
+
+ // Dial gRPC server
+ conn, err := grpc.Dial(*flagServer, grpc.WithInsecure())
+ if err != nil {
+ logger.Fatalf("Failed to dial gRPC server: %v", err)
+ }
+ defer conn.Close()
+
+ statClient := command.NewStatsServiceClient(conn)
+
+ // Open SQLite database
+ db, err := sql.Open("sqlite3", *flagDatabase)
+ if err != nil {
+ logger.Fatalf("Failed to open database: %v", err)
+ }
+ defer db.Close()
+
+ // Create main struct
+ v2stat := &V2Stat{
+ logger: logger,
+ db: db,
+ stat: statClient,
+ }
+
+ // Initialize database schema
+ if err := v2stat.InitDB(); err != nil {
+ logger.Fatalf("Failed to initialize database: %v", err)
+ }
+
+ // For graceful shutdown, create a context that cancels on SIGINT/SIGTERM
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ sigCh := make(chan os.Signal, 1)
+ signal.Notify(sigCh, os.Interrupt, os.Kill)
+
+ // Optional: Query stats once with a reset (as in your original code)
+ if _, err := v2stat.stat.QueryStats(ctx, &command.QueryStatsRequest{Reset_: true}); err != nil {
+ logger.Errorf("Failed to query stats: %v", err)
+ }
+
+ // Wait until the top of the next hour
+ now := time.Now()
+ if now.Minute() != 0 || now.Second() != 0 {
+ nextHour := now.Truncate(time.Hour).Add(time.Hour)
+ subDuration := nextHour.Sub(now)
+ logger.Infof("Waiting for %s to start recording stats", subDuration)
+
+ timer := time.NewTimer(subDuration)
+ select {
+ case <-timer.C:
+ case <-sigCh:
+ logger.Info("Received shutdown signal, exiting.")
+ return
+ }
+ }
+
+ // Start a ticker for every hour
+ ticker := time.NewTicker(1 * time.Hour)
+ defer ticker.Stop()
+ // Main loop
+ for {
+ logger.Info("Recording stats...")
+ if err := v2stat.RecordNow(ctx); err != nil {
+ logger.Errorf("Failed to record stats: %v", err)
+ }
+
+ // Wait for next ticker or shutdown signal
+ select {
+ case <-ticker.C:
+ // just continue the loop and record again
+ case <-sigCh:
+ logger.Info("Received shutdown signal, exiting.")
+ return
+ case <-ctx.Done():
+ logger.Info("Context canceled, exiting.")
+ return
+ }
+ }
+}