From 46359bf2afcfbff157b87cf1011d2e1ff1455a82 Mon Sep 17 00:00:00 2001 From: Clement Date: Sat, 24 Aug 2024 06:27:42 +0800 Subject: [PATCH] feat: detect url status --- .../workflows/template/setup-go-template.yaml | 14 ++ .../template/setup-protobuf-template.yaml | 10 ++ .../workflows/template/setup-ts-template.yaml | 25 +++ .github/workflows/test-go.yml | 26 ++++ .github/workflows/test-ts.yml | 26 ++++ Makefile | 16 +- go/assets/assets.go | 6 + go/internal/config.go | 3 - go/internal/general/general_server.go | 104 +++++++++++++ go/{ => internal/metrics}/metrics_server.go | 53 ++----- go/{ => internal/metrics}/mock_assets.go | 31 ++-- .../metrics}/mock_metrics_server.go | 16 +- go/internal/metrics_type.go | 40 +++++ go/main.go | 8 +- proto/api.proto | 21 +++ ts/components/client/metrics.ts | 15 +- ts/components/hooks/use-graph-data.tsx | 41 +++-- ts/components/hooks/use-pprof-option.tsx | 1 - ts/components/hooks/use-url.tsx | 12 +- ts/components/navbar.tsx | 4 +- ts/components/recorder-button.tsx | 8 +- ts/components/state/pref-state.ts | 2 +- ts/components/{url-chip.tsx => url-bar.tsx} | 8 +- ts/components/url-detect.tsx | 143 +++++++++++++++++- ...{url-input-popover.tsx => url-popover.tsx} | 11 +- ts/components/util/util.ts | 4 + ts/package.json | 3 +- 27 files changed, 531 insertions(+), 120 deletions(-) create mode 100644 .github/workflows/template/setup-go-template.yaml create mode 100644 .github/workflows/template/setup-protobuf-template.yaml create mode 100644 .github/workflows/template/setup-ts-template.yaml create mode 100644 .github/workflows/test-go.yml create mode 100644 .github/workflows/test-ts.yml create mode 100644 go/assets/assets.go delete mode 100644 go/internal/config.go create mode 100644 go/internal/general/general_server.go rename go/{ => internal/metrics}/metrics_server.go (67%) rename go/{ => internal/metrics}/mock_assets.go (79%) rename go/{ => internal/metrics}/mock_metrics_server.go (77%) create mode 100644 go/internal/metrics_type.go rename ts/components/{url-chip.tsx => url-bar.tsx} (81%) rename ts/components/{url-input-popover.tsx => url-popover.tsx} (83%) diff --git a/.github/workflows/template/setup-go-template.yaml b/.github/workflows/template/setup-go-template.yaml new file mode 100644 index 0000000..a515ae8 --- /dev/null +++ b/.github/workflows/template/setup-go-template.yaml @@ -0,0 +1,14 @@ +--- +name: Setup go +on: [ workflow_call ] + +jobs: + - name: Setup go + runs-on: ubuntu-latest + steps: + - uses: actions/setup-go@v5 + with: + go-version: '^1.13.1' + - name: Install protoc-gen-go-grpc + run: go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest + diff --git a/.github/workflows/template/setup-protobuf-template.yaml b/.github/workflows/template/setup-protobuf-template.yaml new file mode 100644 index 0000000..88ac307 --- /dev/null +++ b/.github/workflows/template/setup-protobuf-template.yaml @@ -0,0 +1,10 @@ +--- +name: Setup protobuf +on: [ workflow_call ] + +jobs: + - name: Setup protobuf + runs-on: ubuntu-latest + steps: + - uses: arduino/setup-protoc@v3 + name: Install Protoc \ No newline at end of file diff --git a/.github/workflows/template/setup-ts-template.yaml b/.github/workflows/template/setup-ts-template.yaml new file mode 100644 index 0000000..40edad7 --- /dev/null +++ b/.github/workflows/template/setup-ts-template.yaml @@ -0,0 +1,25 @@ +--- +name: Setup TS +on: [ workflow_call ] + +jobs: + - name: Setup TS + runs-on: ubuntu-latest + steps: + - uses: pnpm/action-setup@v4 + name: Install pnpm + with: + version: 9 + run_install: false + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + - name: Install protoc-gen-js + run: pnpm install -g protoc-gen-js diff --git a/.github/workflows/test-go.yml b/.github/workflows/test-go.yml new file mode 100644 index 0000000..c19258a --- /dev/null +++ b/.github/workflows/test-go.yml @@ -0,0 +1,26 @@ +name: Test go + +on: + push: + branches: [ "main" ] + paths: + - go/** + - proto/** + pull_request: + branches: [ "main" ] + paths: + - go/** + - proto/** + workflow_dispatch: + +jobs: + - name: Test go + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - uses: ./.github/workflows/template/setup-go-template.yaml + + - name: Test + run: make proto test-go diff --git a/.github/workflows/test-ts.yml b/.github/workflows/test-ts.yml new file mode 100644 index 0000000..1a00e85 --- /dev/null +++ b/.github/workflows/test-ts.yml @@ -0,0 +1,26 @@ +name: Test TS + +on: + push: + branches: [ "main" ] + paths: + - ts/** + - proto/** + pull_request: + branches: [ "main" ] + paths: + - ts/** + - proto/** + workflow_dispatch: + +jobs: + - name: Test TS + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - uses: ./.github/workflows/template/setup-ts-template.yaml + + - name: Test + run: make proto test-ts diff --git a/Makefile b/Makefile index 303a66d..84be5a4 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,6 @@ # mode=grpcwebtext prevents client from receiving all the messages at once when using streaming -.PHONY: build proto go ts - -build: proto go ts +build: proto build-go build-ts proto: protoc --go_out=./go/api --go_opt=paths=source_relative --go-grpc_out=./go/api --go-grpc_opt=paths=source_relative --proto_path ./proto ./proto/api.proto @@ -10,12 +8,20 @@ proto: --js_out=import_style=commonjs,binary:./ts/components/api \ --grpc-web_out=import_style=typescript,mode=grpcwebtext:./ts/components/api -go: +build-go: cd go && go build . -ts: +build-ts: cd ts && pnpm build +test: test-ts test-go + +test-go: + cd go && go test ./... + +test-ts: + cd ts && npm test + clean: rm -rf ts/out cd go && go clean diff --git a/go/assets/assets.go b/go/assets/assets.go new file mode 100644 index 0000000..111808e --- /dev/null +++ b/go/assets/assets.go @@ -0,0 +1,6 @@ +package assets + +import "embed" + +//go:embed mock-data +var MockData embed.FS diff --git a/go/internal/config.go b/go/internal/config.go deleted file mode 100644 index 729760e..0000000 --- a/go/internal/config.go +++ /dev/null @@ -1,3 +0,0 @@ -package internal - -const () diff --git a/go/internal/general/general_server.go b/go/internal/general/general_server.go new file mode 100644 index 0000000..19c8ae7 --- /dev/null +++ b/go/internal/general/general_server.go @@ -0,0 +1,104 @@ +package general + +import ( + "context" + "io" + "net/http" + "strings" + "sync" + "time" + + "github.com/moderato-app/live-pprof/api" + "github.com/moderato-app/live-pprof/internal" +) + +type GeneralServer struct { + api.UnimplementedGeneralServer +} + +func NewGeneralServer() *GeneralServer { + return &GeneralServer{} +} + +func (m *GeneralServer) DetectURL(req *api.DetectURLRequest, stream api.General_DetectURLServer) error { + internal.Sugar.Debug("DetectURL req:", req) + wg := sync.WaitGroup{} + mts := [...]internal.MetricsType{ + internal.MetricsTypeHeap, + internal.MetricsTypeCPU, + internal.MetricsTypeAllocs, + internal.MetricsTypeGoroutine, + } + + urls := make([]string, 0, 5) + urls = append(urls, req.Url) + for _, mt := range mts { + url, err := internal.MetricsURL(req.Url, mt, true) + if err != nil { + internal.Sugar.Error(err) + panic(err) + } + urls = append(urls, url) + } + + for _, url := range urls { + wg.Add(1) + go func(url string) { + defer wg.Done() + ctx, cancel := context.WithTimeout(stream.Context(), 3*time.Second) + defer cancel() + result, err := detect(ctx, url) + var errMsg *string + if err != nil { + msg := err.Error() + errMsg = &msg + } + resp := api.DetectURLResponse{ + Endpoint: url, + HttpResult: result, + Error: errMsg, + } + err = stream.Send(&resp) + if err != nil { + internal.Sugar.Error(err) + } + }(url) + } + wg.Wait() + return nil +} + +func detect(ctx context.Context, url string) (*api.HTTPResult, error) { + client := &http.Client{} + internal.Sugar.Debugf("GET %s", url) + + method := http.MethodGet + + // don't fetch /profile endpoint since only returns data in pb.gz format + if strings.Contains(url, "/profile?seconds=") { + method = http.MethodHead + } + req, err := http.NewRequestWithContext(ctx, method, url, nil) + if err != nil { + return nil, err + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + data, err := io.ReadAll(resp.Body) + + if resp.StatusCode < 200 || resp.StatusCode > 399 { + internal.Sugar.Info("resp.Body: \n" + string(data)) + } + + bodyStr := string(data) + + return &api.HTTPResult{ + StatusCode: int32(resp.StatusCode), + StatusText: resp.Status, + Body: &bodyStr, + }, nil +} diff --git a/go/metrics_server.go b/go/internal/metrics/metrics_server.go similarity index 67% rename from go/metrics_server.go rename to go/internal/metrics/metrics_server.go index 1e322b5..4feddc1 100644 --- a/go/metrics_server.go +++ b/go/internal/metrics/metrics_server.go @@ -1,11 +1,10 @@ -package main +package metrics import ( "context" "errors" "io" "net/http" - "net/url" "time" "github.com/moderato-app/live-pprof/api" @@ -13,45 +12,36 @@ import ( "github.com/moderato-app/pprof/moderato" ) -type MetricsType int - -const ( - MetricsTypeHeap = MetricsType(iota) - MetricsTypeCPU - MetricsTypeAllocs - MetricsTypeGoroutine -) - type MetricsServer struct { api.UnimplementedMetricsServer } -func newMetricsServer() *MetricsServer { +func NewMetricsServer() *MetricsServer { return &MetricsServer{} } func (m *MetricsServer) HeapMetrics(ctx context.Context, req *api.GoMetricsRequest) (*api.GoMetricsResponse, error) { internal.Sugar.Debug("HeapMetrics req:", req) - return dispatch(ctx, req, MetricsTypeHeap) + return dispatch(ctx, req, internal.MetricsTypeHeap) } func (m *MetricsServer) CPUMetrics(ctx context.Context, req *api.GoMetricsRequest) (*api.GoMetricsResponse, error) { internal.Sugar.Debug("CPUMetrics req:", req) - return dispatch(ctx, req, MetricsTypeCPU) + return dispatch(ctx, req, internal.MetricsTypeCPU) } func (m *MetricsServer) AllocsMetrics(ctx context.Context, req *api.GoMetricsRequest) (*api.GoMetricsResponse, error) { internal.Sugar.Debug("AllocsMetrics req:", req) - return dispatch(ctx, req, MetricsTypeAllocs) + return dispatch(ctx, req, internal.MetricsTypeAllocs) } func (m *MetricsServer) GoroutineMetrics(ctx context.Context, req *api.GoMetricsRequest) (*api.GoMetricsResponse, error) { internal.Sugar.Debug("GoroutineMetrics req:", req) - return dispatch(ctx, req, MetricsTypeGoroutine) + return dispatch(ctx, req, internal.MetricsTypeGoroutine) } -func dispatch(ctx context.Context, req *api.GoMetricsRequest, mt MetricsType) (*api.GoMetricsResponse, error) { - u, err := prepareUrl(req.Url, mt) +func dispatch(ctx context.Context, req *api.GoMetricsRequest, mt internal.MetricsType) (*api.GoMetricsResponse, error) { + u, err := internal.MetricsURL(req.Url, mt, false) if err != nil { internal.Sugar.Error(err) return nil, err @@ -86,7 +76,7 @@ func fetch(ctx context.Context, url string) (*moderato.Metrics, error) { if resp.StatusCode < 200 || resp.StatusCode > 299 { internal.Sugar.Error("resp.Body: \n" + string(data)) - return nil, errors.New("bad status code: " + resp.Status) + return nil, errors.New("bad status code: " + resp.Status + ". " + string(data)) } mtr, err := moderato.GetMetricsFromData(data) @@ -99,31 +89,6 @@ func fetch(ctx context.Context, url string) (*moderato.Metrics, error) { return mtr, nil } -func prepareUrl(baseUrl string, mt MetricsType) (string, error) { - parse, err := url.Parse(baseUrl) - if err != nil { - return "", errors.New("invalid url: " + baseUrl) - } - - var u string - if mt == MetricsTypeHeap { - u = parse.JoinPath("heap").String() - } else if mt == MetricsTypeAllocs { - u = parse.JoinPath("allocs").String() - } else if mt == MetricsTypeGoroutine { - u = parse.JoinPath("goroutine").String() - } else if mt == MetricsTypeCPU { - j := parse.JoinPath("profile") - params := url.Values{} - params.Add("seconds", "1") - j.RawQuery = params.Encode() - u = j.String() - } else { - return "", errors.New("invalid fetch type") - } - return u, nil -} - func toResp(mtr *moderato.Metrics) *api.GoMetricsResponse { var pbItems []*api.Item diff --git a/go/mock_assets.go b/go/internal/metrics/mock_assets.go similarity index 79% rename from go/mock_assets.go rename to go/internal/metrics/mock_assets.go index 713b7d8..e299fc8 100644 --- a/go/mock_assets.go +++ b/go/internal/metrics/mock_assets.go @@ -1,24 +1,22 @@ -package main +package metrics import ( - "embed" "errors" + "os" "path/filepath" "sort" "sync" "sync/atomic" + "github.com/moderato-app/live-pprof/assets" "github.com/moderato-app/live-pprof/internal" "github.com/moderato-app/pprof/moderato" ) -//go:embed assets -var assets embed.FS - -const cpuDir = "assets/mock-data/cpu" -const heap = "assets/mock-data/heap" -const allocs = "assets/mock-data/allocs" -const goroutine = "assets/mock-data/goroutine" +const cpuDir = "mock-data/cpu" +const heap = "mock-data/heap" +const allocs = "mock-data/allocs" +const goroutine = "mock-data/goroutine" type MockAssets struct { loadProfilesOnce func() @@ -32,6 +30,7 @@ type MockAssets struct { func newMockAssets() *MockAssets { m := &MockAssets{} + internal.Sugar.Info(os.Getwd()) m.loadProfilesOnce = sync.OnceFunc(func() { err := walkDirNonRecursive(cpuDir, func(data []byte) { m.cpuProfiles = append(m.cpuProfiles, data) @@ -76,19 +75,19 @@ func newMockAssets() *MockAssets { return m } -func (m *MockAssets) GetMetrics(mt MetricsType) (*moderato.Metrics, error) { +func (m *MockAssets) GetMetrics(mt internal.MetricsType) (*moderato.Metrics, error) { m.loadProfilesOnce() cnt := m.mockCount.Add(1) var profile []byte - if mt == MetricsTypeHeap { + if mt == internal.MetricsTypeHeap { profile = m.heapProfiles[cnt%int64(len(m.cpuProfiles))] - } else if mt == MetricsTypeCPU { + } else if mt == internal.MetricsTypeCPU { profile = m.cpuProfiles[cnt%int64(len(m.cpuProfiles))] - } else if mt == MetricsTypeAllocs { + } else if mt == internal.MetricsTypeAllocs { profile = m.allocsProfiles[cnt%int64(len(m.allocsProfiles))] - } else if mt == MetricsTypeGoroutine { + } else if mt == internal.MetricsTypeGoroutine { profile = m.goroutineProfiles[cnt%int64(len(m.goroutineProfiles))] } else { return nil, errors.New("invalid fetch type") @@ -105,7 +104,7 @@ func (m *MockAssets) GetMetrics(mt MetricsType) (*moderato.Metrics, error) { } func walkDirNonRecursive(dir string, f func(data []byte)) error { - entries, err := assets.ReadDir(dir) + entries, err := assets.MockData.ReadDir(dir) sort.Slice(entries, func(i, j int) bool { return entries[i].Name() <= entries[j].Name() }) if err != nil { return err @@ -114,7 +113,7 @@ func walkDirNonRecursive(dir string, f func(data []byte)) error { for _, entry := range entries { if !entry.IsDir() { fPath := filepath.Join(dir, entry.Name()) - data, err := assets.ReadFile(fPath) + data, err := assets.MockData.ReadFile(fPath) if err != nil { return err } diff --git a/go/mock_metrics_server.go b/go/internal/metrics/mock_metrics_server.go similarity index 77% rename from go/mock_metrics_server.go rename to go/internal/metrics/mock_metrics_server.go index 2c93f20..215d243 100644 --- a/go/mock_metrics_server.go +++ b/go/internal/metrics/mock_metrics_server.go @@ -1,4 +1,4 @@ -package main +package metrics import ( "context" @@ -14,7 +14,7 @@ type MockMetricsServer struct { mockResources *MockAssets } -func newMockMetricsServer() *MockMetricsServer { +func NewMockMetricsServer() *MockMetricsServer { return &MockMetricsServer{ mockResources: newMockAssets(), } @@ -22,26 +22,26 @@ func newMockMetricsServer() *MockMetricsServer { func (m *MockMetricsServer) HeapMetrics(_ context.Context, req *api.GoMetricsRequest) (*api.GoMetricsResponse, error) { internal.Sugar.Debug("HeapMetrics req:", req) - return m.dispatch(req, MetricsTypeHeap) + return m.dispatch(req, internal.MetricsTypeHeap) } func (m *MockMetricsServer) CPUMetrics(_ context.Context, req *api.GoMetricsRequest) (*api.GoMetricsResponse, error) { internal.Sugar.Debug("CPUMetrics req:", req) - return m.dispatch(req, MetricsTypeCPU) + return m.dispatch(req, internal.MetricsTypeCPU) } func (m *MockMetricsServer) AllocsMetrics(_ context.Context, req *api.GoMetricsRequest) (*api.GoMetricsResponse, error) { internal.Sugar.Debug("AllocsMetrics req:", req) - return m.dispatch(req, MetricsTypeAllocs) + return m.dispatch(req, internal.MetricsTypeAllocs) } func (m *MockMetricsServer) GoroutineMetrics(_ context.Context, req *api.GoMetricsRequest) (*api.GoMetricsResponse, error) { internal.Sugar.Debug("GoroutineMetrics req:", req) - return m.dispatch(req, MetricsTypeGoroutine) + return m.dispatch(req, internal.MetricsTypeGoroutine) } -func (m *MockMetricsServer) dispatch(req *api.GoMetricsRequest, mt MetricsType) (*api.GoMetricsResponse, error) { - _, err := prepareUrl(req.Url, mt) +func (m *MockMetricsServer) dispatch(req *api.GoMetricsRequest, mt internal.MetricsType) (*api.GoMetricsResponse, error) { + _, err := internal.MetricsURL(req.Url, mt, false) if err != nil { return nil, err } diff --git a/go/internal/metrics_type.go b/go/internal/metrics_type.go new file mode 100644 index 0000000..4bf7713 --- /dev/null +++ b/go/internal/metrics_type.go @@ -0,0 +1,40 @@ +package internal + +import ( + "errors" + "net/url" +) + +type MetricsType string + +const ( + MetricsTypeHeap = "heap" + MetricsTypeCPU = "profile" + MetricsTypeAllocs = "allocs" + MetricsTypeGoroutine = "goroutine" +) + +// MetricsURL generates a URL that works with Go's net/http/pprof. +// +// If debug is true, it fetches data in text format(except for MetricsTypeCPU). +// If debug is false, it fetches data in pb.gz format. +func MetricsURL(baseUrl string, mt MetricsType, debug bool) (string, error) { + parse, err := url.Parse(baseUrl) + if err != nil { + return "", errors.New("invalid url: " + baseUrl) + } + + var p *url.URL + params := url.Values{} + + p = parse.JoinPath(string(mt)) + + if mt == MetricsTypeCPU { + params.Add("seconds", "1") + } else if debug { + params.Add("debug", "1") + } + p.RawQuery = params.Encode() + + return p.String(), nil +} diff --git a/go/main.go b/go/main.go index 7b9dd39..0245c2e 100644 --- a/go/main.go +++ b/go/main.go @@ -3,6 +3,8 @@ package main import ( "github.com/moderato-app/live-pprof/api" "github.com/moderato-app/live-pprof/internal" + "github.com/moderato-app/live-pprof/internal/general" + "github.com/moderato-app/live-pprof/internal/metrics" "google.golang.org/grpc" ) @@ -10,7 +12,9 @@ func main() { var opts []grpc.ServerOption grpcServer := grpc.NewServer(opts...) - api.RegisterMetricsServer(grpcServer, newMetricsServer()) - api.RegisterMockMetricsServer(grpcServer, newMockMetricsServer()) + api.RegisterMetricsServer(grpcServer, metrics.NewMetricsServer()) + api.RegisterMockMetricsServer(grpcServer, metrics.NewMockMetricsServer()) + api.RegisterGeneralServer(grpcServer, general.NewGeneralServer()) + internal.StartServeGrpc(grpcServer) } diff --git a/proto/api.proto b/proto/api.proto index fcf2477..4f11a86 100644 --- a/proto/api.proto +++ b/proto/api.proto @@ -18,6 +18,10 @@ service MockMetrics { rpc GoroutineMetrics(GoMetricsRequest) returns (GoMetricsResponse); } +service General { + rpc DetectURL(DetectURLRequest) returns (stream DetectURLResponse); +} + message GoMetricsRequest { string url = 1; } @@ -34,3 +38,20 @@ message Item { int64 flat = 3 ; int64 cum = 4; } + +message DetectURLRequest { + string url = 1; +} + +message DetectURLResponse { + string endpoint = 1; + + optional HTTPResult httpResult = 2; + optional string error = 4; +} + +message HTTPResult{ + int32 statusCode = 1; + string statusText = 2; + optional string body = 3; +} \ No newline at end of file diff --git a/ts/components/client/metrics.ts b/ts/components/client/metrics.ts index 8a822ee..5a7b543 100644 --- a/ts/components/client/metrics.ts +++ b/ts/components/client/metrics.ts @@ -1,13 +1,18 @@ import { useSnapshot } from 'valtio/react' +import { useMemo } from 'react' -import { MetricsClient, MockMetricsClient } from '@/components/api/ApiServiceClientPb' +import { GeneralClient, MetricsClient, MockMetricsClient } from '@/components/api/ApiServiceClientPb' import { graphPrefsState } from '@/components/state/pref-state' -export const metricClient = new MetricsClient('http://localhost:8080') -export const mockMetricClient = new MockMetricsClient('http://localhost:8080') - export const useMetricsClient = (): MetricsClient => { const { mock } = useSnapshot(graphPrefsState) - return mock ? mockMetricClient : metricClient + return useMemo( + () => (mock ? new MockMetricsClient('http://localhost:8080') : new MetricsClient('http://localhost:8080')), + [mock] + ) +} + +export const useGeneralClient = (): GeneralClient => { + return useMemo(() => new GeneralClient('http://localhost:8080'), []) } diff --git a/ts/components/hooks/use-graph-data.tsx b/ts/components/hooks/use-graph-data.tsx index 21eb07a..b5d9380 100644 --- a/ts/components/hooks/use-graph-data.tsx +++ b/ts/components/hooks/use-graph-data.tsx @@ -2,6 +2,8 @@ import { useEffect, useState } from 'react' import grpcWeb from 'grpc-web' import { useSnapshot } from 'valtio/react' +import dayjs from 'dayjs' +import { defer } from 'lodash' import { GraphData, newGraphData } from '@/components/charts/data-structure' import { useMetricsClient } from '@/components/client/metrics' @@ -26,7 +28,7 @@ export type GraphDataProps = { export const useGraphData = ({ pprofType }: GraphDataProps): GraphData => { const [graphData, setGraphData] = useState(newGraphData()) const client = useMetricsClient() - const url = useURL() + const { url } = useURL() const { isRecording } = useSnapshot(recorderState) useEffect(() => { @@ -42,10 +44,23 @@ export const useGraphData = ({ pprofType }: GraphDataProps): GraphData => { if (typeof url !== 'string') { return } - const req = new GoMetricsRequest().setUrl(url) + let u = url const streams: grpcWeb.ClientReadableStream[] = [] - const t = setInterval(() => { + let t: NodeJS.Timeout + let lastRequest = dayjs() + + const makeRequest = () => { const callback = (err: grpcWeb.RpcError, response: GoMetricsResponse) => { + defer(() => { + let waitForMs = 1000 + // cpu request takes about 1 second to complete, so there is no need to wait for 1 second again, + // but we need to wait if cpu request fails too fast + if (pprofType === PprofType.cpu && dayjs().diff(lastRequest, 'millisecond') > 1000) { + waitForMs = 0 + } + t = setTimeout(makeRequest, waitForMs) + }) + // remove stream from the list const index = streams.indexOf(stream) if (index > -1) { @@ -63,27 +78,31 @@ export const useGraphData = ({ pprofType }: GraphDataProps): GraphData => { } let stream: grpcWeb.ClientReadableStream + const meta = { deadline: dayjs().add(5, 'seconds').toDate().getTime().toString() } + lastRequest = dayjs() + const req = new GoMetricsRequest().setUrl(u) switch (pprofType) { case PprofType.cpu: - stream = client.cPUMetrics(req, null, callback) + stream = client.cPUMetrics(req, meta, callback) break case PprofType.heap: - stream = client.heapMetrics(req, null, callback) + stream = client.heapMetrics(req, meta, callback) break case PprofType.allocs: - stream = client.allocsMetrics(req, null, callback) + stream = client.allocsMetrics(req, meta, callback) break case PprofType.goroutine: - stream = client.goroutineMetrics(req, null, callback) + stream = client.goroutineMetrics(req, meta, callback) break } streams.push(stream) - }, 1000) - - if (streams.length > 5) { - console.warn('${metricsFunc} created too many concurrent streams. consider set a timeout for each stream') + if (streams.length > 5) { + console.warn('${metricsFunc} created too many concurrent streams. consider set a timeout for each stream') + } } + t = setTimeout(makeRequest, 0) + return () => { clearTimeout(t) streams.forEach(p => p.cancel()) diff --git a/ts/components/hooks/use-pprof-option.tsx b/ts/components/hooks/use-pprof-option.tsx index 4a41f65..c577de5 100644 --- a/ts/components/hooks/use-pprof-option.tsx +++ b/ts/components/hooks/use-pprof-option.tsx @@ -12,7 +12,6 @@ import { dispatchGraphPrefProxy, FlatOrCum, graphPrefsState } from '@/components import { useBasicOption } from '@/components/hooks/use-basic-option' import prettyTime from '@/components/util/prettyTime' import { PprofType, useGraphData } from '@/components/hooks/use-graph-data' -import { newGraphData } from '@/components/charts/data-structure' import { myEmitter } from '@/components/state/emitter' type PprofProps = { diff --git a/ts/components/hooks/use-url.tsx b/ts/components/hooks/use-url.tsx index 8c56453..3a8540b 100644 --- a/ts/components/hooks/use-url.tsx +++ b/ts/components/hooks/use-url.tsx @@ -1,21 +1,19 @@ 'use client' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useMemo } from 'react' import { useSnapshot } from 'valtio/react' import getUrls from 'get-urls' import { graphPrefsState } from '@/components/state/pref-state' export const useURL = (): { url: string | Error; input: string; setInput: (input: string) => void } => { - const { inputURL } = useSnapshot(graphPrefsState) - const [url, setUrl] = useState(Error('Initial')) + const { inputURL } = useSnapshot(graphPrefsState, { sync: true }) + const setInput = useCallback((input: string) => { graphPrefsState.inputURL = input }, []) - useEffect(() => { - let gen = generateUrl(inputURL) - setUrl(gen) - }, [inputURL, setUrl]) + + const url = useMemo(() => generateUrl(inputURL), [inputURL]) return { url: url, input: inputURL, setInput: setInput } } diff --git a/ts/components/navbar.tsx b/ts/components/navbar.tsx index 0780ca3..af38e57 100644 --- a/ts/components/navbar.tsx +++ b/ts/components/navbar.tsx @@ -10,7 +10,7 @@ import { MockSwitch } from '@/components/mock-switch' import { RecorderButton } from '@/components/recorder-button' import { RecorderTime } from '@/components/recorder-time' import { HomeMenu } from '@/components/home-menu' -import UrlChip from '@/components/url-chip' +import UrlBar from '@/components/url-bar' export const Navbar = () => { return ( @@ -22,7 +22,7 @@ export const Navbar = () => {
    - + diff --git a/ts/components/recorder-button.tsx b/ts/components/recorder-button.tsx index 1956027..8f032df 100644 --- a/ts/components/recorder-button.tsx +++ b/ts/components/recorder-button.tsx @@ -11,7 +11,7 @@ import { useURL } from '@/components/hooks/use-url' export const RecorderButton = () => { const { isRecording } = useSnapshot(recorderState) - const url = useURL() + const { url } = useURL() const tooltipInfo = isRecording ? 'Stop' : 'Start' const start = useCallback(() => { @@ -30,7 +30,7 @@ export const RecorderButton = () => { aria-label={tooltipInfo} className="text-default-500 dark:text-default-foreground" variant={'light'} - onPress={stop} + onClick={stop} > @@ -39,9 +39,9 @@ export const RecorderButton = () => { isIconOnly aria-label={tooltipInfo} className="text-default-500 dark:text-default-foreground" - disabled={url instanceof Error} + isDisabled={url instanceof Error} variant={'light'} - onPress={start} + onClick={start} > diff --git a/ts/components/state/pref-state.ts b/ts/components/state/pref-state.ts index 2c395a5..fc62e4c 100644 --- a/ts/components/state/pref-state.ts +++ b/ts/components/state/pref-state.ts @@ -3,7 +3,7 @@ import { proxy, subscribe } from 'valtio' import { PprofType } from '@/components/hooks/use-graph-data' -const graphsPrefsLSKey = 'graph prefs v20' +const graphsPrefsLSKey = 'graph prefs v21' export enum FlatOrCum { flat = 'flat', diff --git a/ts/components/url-chip.tsx b/ts/components/url-bar.tsx similarity index 81% rename from ts/components/url-chip.tsx rename to ts/components/url-bar.tsx index 74c4c73..4361fec 100644 --- a/ts/components/url-chip.tsx +++ b/ts/components/url-bar.tsx @@ -2,12 +2,14 @@ import React from 'react' import { Popover, PopoverContent, PopoverTrigger } from '@nextui-org/popover' import { Chip } from '@nextui-org/chip' +import { useIsSSR } from '@react-aria/ssr' -import { UrlInputPopover } from '@/components/url-input-popover' +import { UrlPopover } from '@/components/url-popover' import { useURL } from '@/components/hooks/use-url' -export default function UrlChip() { +export default function UrlBar() { const { url } = useURL() + if (useIsSSR()) return null return (
    @@ -22,7 +24,7 @@ export default function UrlChip() { )} - +
    diff --git a/ts/components/url-detect.tsx b/ts/components/url-detect.tsx index ba92d2d..a0d84d1 100644 --- a/ts/components/url-detect.tsx +++ b/ts/components/url-detect.tsx @@ -1,19 +1,156 @@ 'use client' -import React, { FC } from 'react' +import React, { FC, useCallback, useEffect, useState } from 'react' import { Chip } from '@nextui-org/chip' +import { ClientReadableStream, RpcError } from 'grpc-web' +import { Icon } from '@iconify/react' +import { Spacer } from '@nextui-org/spacer' +import { Accordion, AccordionItem } from '@nextui-org/accordion' + +import { useGeneralClient } from '@/components/client/metrics' +import { DetectURLRequest, DetectURLResponse } from '@/components/api/api_pb' interface UrlDetectProps { url: string } +enum Status { + idle, + loading, + done, +} + export const UrlDetect: FC = ({ url }) => { + const [status, setStatus] = useState(Status.idle) + const [results, setResults] = useState([]) + const client = useGeneralClient() + const [rpcError, setRpcError] = useState() + + const reset = useCallback(() => { + setResults([]) + setStatus(Status.idle) + setRpcError(undefined) + }, [setRpcError, setStatus, setResults]) + + useEffect(() => { + reset() + let stream: ClientReadableStream + const t = setTimeout(() => { + const req = new DetectURLRequest().setUrl(url) + + stream = client.detectURL(req) + setStatus(Status.loading) + // sleep for 1 second + + stream.on('data', response => { + console.debug('on data', response) + setResults(prev => [...prev, response]) + }) + stream.on('end', () => { + setStatus(Status.done) + }) + stream.on('error', rpcError => { + console.debug('on error', rpcError) + setRpcError(rpcError) + setStatus(Status.done) + }) + }, 200) + + return () => { + stream && stream.cancel() + clearTimeout(t) + } + }, [url, setResults]) + return ( -
    - +
    + {url} + + +
    + {rpcError && ( +
    + +
    {rpcError.message}
    +
    + )} + + {results.map(r => ( + + } + title={r.getEndpoint()} + > + + + ))} + + {status === Status.loading && } +
    ) } + +interface ContentProps { + httpBody?: string + error?: string +} + +const Content: FC = ({ httpBody, error }) => { + if (httpBody || error) { + return ( +
    + {httpBody && ( +
    { + // disable links + e.preventDefault() + }} + /> + )} + {error &&

    {error}

    } +
    + ) + } else { + return null + } +} + +interface IndicatorProps { + httpStatusCode?: number + httpStatusText?: string + httpBody?: string + error?: string +} + +export const AccordionIcon: FC = ({ httpStatusCode, httpStatusText, error }) => { + return ( +
    + {httpStatusCode && ( + = 200 && httpStatusCode < 399 ? 'success' : 'danger'} + size={'sm'} + > + {httpStatusText ?? httpStatusCode} + + )} + {error && } +
    + ) +} diff --git a/ts/components/url-input-popover.tsx b/ts/components/url-popover.tsx similarity index 83% rename from ts/components/url-input-popover.tsx rename to ts/components/url-popover.tsx index 8038b1b..08d765c 100644 --- a/ts/components/url-input-popover.tsx +++ b/ts/components/url-popover.tsx @@ -2,23 +2,27 @@ import { Input } from '@nextui-org/input' import React, { useMemo } from 'react' +import { useIsSSR } from '@react-aria/ssr' import { useURL } from '@/components/hooks/use-url' import { UrlDetect } from '@/components/url-detect' -export const UrlInputPopover = () => { +export const UrlPopover = () => { const { url, input, setInput } = useURL() const isInvalid = useMemo(() => { return url instanceof Error }, [url]) + if (useIsSSR()) { + return null + } + const placeholder = '8080, localhost:8080 or http://localhost:8080/debug/pprof' return ( -
    +
    {/* to provide auto inferred width for */}
    {placeholder}6chars6chars
    - { onValueChange={setInput} /> {typeof url === 'string' && } -
    {placeholder}6chars
    ) } diff --git a/ts/components/util/util.ts b/ts/components/util/util.ts index fed5c26..6375870 100644 --- a/ts/components/util/util.ts +++ b/ts/components/util/util.ts @@ -28,3 +28,7 @@ export const formatMillis = (millis: number): string => { } return duration } + +export const delay = (ms: number) => { + return new Promise(resolve => setTimeout(resolve, ms)) +} diff --git a/ts/package.json b/ts/package.json index 095fbbc..4345e5c 100644 --- a/ts/package.json +++ b/ts/package.json @@ -6,10 +6,11 @@ "dev": "next dev --turbo", "build": "next build", "lint": "eslint . --ext .ts,.tsx -c .eslintrc.json --fix", - "test": "vitest" + "test": "vitest --run" }, "dependencies": { "@grpc/grpc-js": "^1.11.1", + "@nextui-org/accordion": "^2.0.38", "@nextui-org/button": "2.0.37", "@nextui-org/card": "^2.0.33", "@nextui-org/checkbox": "^2.1.4",