mirror of https://github.com/go-gitea/gitea.git
Support performance trace (#32973)
1. Add a OpenTelemetry-like shim-layer to collect traces 2. Add a simple builtin trace collector and exporter, end users could download the diagnosis report to get the traces. This PR's design is quite lightweight, no hard-dependency, and it is easy to improve or remove. We can try it on gitea.com first to see whether it works well, and fine tune the details. --------- Co-authored-by: silverwind <me@silverwind.io>pull/33345/head
parent
2cb3946496
commit
7069369e03
@ -0,0 +1,32 @@
|
|||||||
|
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package gtprof
|
||||||
|
|
||||||
|
type EventConfig struct {
|
||||||
|
attributes []*TraceAttribute
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventOption interface {
|
||||||
|
applyEvent(*EventConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
type applyEventFunc func(*EventConfig)
|
||||||
|
|
||||||
|
func (f applyEventFunc) applyEvent(cfg *EventConfig) {
|
||||||
|
f(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithAttributes(attrs ...*TraceAttribute) EventOption {
|
||||||
|
return applyEventFunc(func(cfg *EventConfig) {
|
||||||
|
cfg.attributes = append(cfg.attributes, attrs...)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func eventConfigFromOptions(options ...EventOption) *EventConfig {
|
||||||
|
cfg := &EventConfig{}
|
||||||
|
for _, opt := range options {
|
||||||
|
opt.applyEvent(cfg)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
@ -0,0 +1,175 @@
|
|||||||
|
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package gtprof
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
type contextKey struct {
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
|
var contextKeySpan = &contextKey{"span"}
|
||||||
|
|
||||||
|
type traceStarter interface {
|
||||||
|
start(ctx context.Context, traceSpan *TraceSpan, internalSpanIdx int) (context.Context, traceSpanInternal)
|
||||||
|
}
|
||||||
|
|
||||||
|
type traceSpanInternal interface {
|
||||||
|
addEvent(name string, cfg *EventConfig)
|
||||||
|
recordError(err error, cfg *EventConfig)
|
||||||
|
end()
|
||||||
|
}
|
||||||
|
|
||||||
|
type TraceSpan struct {
|
||||||
|
// immutable
|
||||||
|
parent *TraceSpan
|
||||||
|
internalSpans []traceSpanInternal
|
||||||
|
internalContexts []context.Context
|
||||||
|
|
||||||
|
// mutable, must be protected by mutex
|
||||||
|
mu sync.RWMutex
|
||||||
|
name string
|
||||||
|
statusCode uint32
|
||||||
|
statusDesc string
|
||||||
|
startTime time.Time
|
||||||
|
endTime time.Time
|
||||||
|
attributes []*TraceAttribute
|
||||||
|
children []*TraceSpan
|
||||||
|
}
|
||||||
|
|
||||||
|
type TraceAttribute struct {
|
||||||
|
Key string
|
||||||
|
Value TraceValue
|
||||||
|
}
|
||||||
|
|
||||||
|
type TraceValue struct {
|
||||||
|
v any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TraceValue) AsString() string {
|
||||||
|
return fmt.Sprint(t.v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TraceValue) AsInt64() int64 {
|
||||||
|
v, _ := util.ToInt64(t.v)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TraceValue) AsFloat64() float64 {
|
||||||
|
v, _ := util.ToFloat64(t.v)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
var globalTraceStarters []traceStarter
|
||||||
|
|
||||||
|
type Tracer struct {
|
||||||
|
starters []traceStarter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TraceSpan) SetName(name string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.name = name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TraceSpan) SetStatus(code uint32, desc string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.statusCode, s.statusDesc = code, desc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TraceSpan) AddEvent(name string, options ...EventOption) {
|
||||||
|
cfg := eventConfigFromOptions(options...)
|
||||||
|
for _, tsp := range s.internalSpans {
|
||||||
|
tsp.addEvent(name, cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TraceSpan) RecordError(err error, options ...EventOption) {
|
||||||
|
cfg := eventConfigFromOptions(options...)
|
||||||
|
for _, tsp := range s.internalSpans {
|
||||||
|
tsp.recordError(err, cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TraceSpan) SetAttributeString(key, value string) *TraceSpan {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
s.attributes = append(s.attributes, &TraceAttribute{Key: key, Value: TraceValue{v: value}})
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tracer) Start(ctx context.Context, spanName string) (context.Context, *TraceSpan) {
|
||||||
|
starters := t.starters
|
||||||
|
if starters == nil {
|
||||||
|
starters = globalTraceStarters
|
||||||
|
}
|
||||||
|
ts := &TraceSpan{name: spanName, startTime: time.Now()}
|
||||||
|
parentSpan := GetContextSpan(ctx)
|
||||||
|
if parentSpan != nil {
|
||||||
|
parentSpan.mu.Lock()
|
||||||
|
parentSpan.children = append(parentSpan.children, ts)
|
||||||
|
parentSpan.mu.Unlock()
|
||||||
|
ts.parent = parentSpan
|
||||||
|
}
|
||||||
|
|
||||||
|
parentCtx := ctx
|
||||||
|
for internalSpanIdx, tsp := range starters {
|
||||||
|
var internalSpan traceSpanInternal
|
||||||
|
if parentSpan != nil {
|
||||||
|
parentCtx = parentSpan.internalContexts[internalSpanIdx]
|
||||||
|
}
|
||||||
|
ctx, internalSpan = tsp.start(parentCtx, ts, internalSpanIdx)
|
||||||
|
ts.internalContexts = append(ts.internalContexts, ctx)
|
||||||
|
ts.internalSpans = append(ts.internalSpans, internalSpan)
|
||||||
|
}
|
||||||
|
ctx = context.WithValue(ctx, contextKeySpan, ts)
|
||||||
|
return ctx, ts
|
||||||
|
}
|
||||||
|
|
||||||
|
type mutableContext interface {
|
||||||
|
context.Context
|
||||||
|
SetContextValue(key, value any)
|
||||||
|
GetContextValue(key any) any
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartInContext starts a trace span in Gitea's mutable context (usually the web request context).
|
||||||
|
// Due to the design limitation of Gitea's web framework, it can't use `context.WithValue` to bind a new span into a new context.
|
||||||
|
// So here we use our "reqctx" framework to achieve the same result: web request context could always see the latest "span".
|
||||||
|
func (t *Tracer) StartInContext(ctx mutableContext, spanName string) (*TraceSpan, func()) {
|
||||||
|
curTraceSpan := GetContextSpan(ctx)
|
||||||
|
_, newTraceSpan := GetTracer().Start(ctx, spanName)
|
||||||
|
ctx.SetContextValue(contextKeySpan, newTraceSpan)
|
||||||
|
return newTraceSpan, func() {
|
||||||
|
newTraceSpan.End()
|
||||||
|
ctx.SetContextValue(contextKeySpan, curTraceSpan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TraceSpan) End() {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.endTime = time.Now()
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
for _, tsp := range s.internalSpans {
|
||||||
|
tsp.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetTracer() *Tracer {
|
||||||
|
return &Tracer{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetContextSpan(ctx context.Context) *TraceSpan {
|
||||||
|
ts, _ := ctx.Value(contextKeySpan).(*TraceSpan)
|
||||||
|
return ts
|
||||||
|
}
|
@ -0,0 +1,96 @@
|
|||||||
|
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package gtprof
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/tailmsg"
|
||||||
|
)
|
||||||
|
|
||||||
|
type traceBuiltinStarter struct{}
|
||||||
|
|
||||||
|
type traceBuiltinSpan struct {
|
||||||
|
ts *TraceSpan
|
||||||
|
|
||||||
|
internalSpanIdx int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *traceBuiltinSpan) addEvent(name string, cfg *EventConfig) {
|
||||||
|
// No-op because builtin tracer doesn't need it.
|
||||||
|
// In the future we might use it to mark the time point between backend logic and network response.
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *traceBuiltinSpan) recordError(err error, cfg *EventConfig) {
|
||||||
|
// No-op because builtin tracer doesn't need it.
|
||||||
|
// Actually Gitea doesn't handle err this way in most cases
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *traceBuiltinSpan) toString(out *strings.Builder, indent int) {
|
||||||
|
t.ts.mu.RLock()
|
||||||
|
defer t.ts.mu.RUnlock()
|
||||||
|
|
||||||
|
out.WriteString(strings.Repeat(" ", indent))
|
||||||
|
out.WriteString(t.ts.name)
|
||||||
|
if t.ts.endTime.IsZero() {
|
||||||
|
out.WriteString(" duration: (not ended)")
|
||||||
|
} else {
|
||||||
|
out.WriteString(fmt.Sprintf(" duration=%.4fs", t.ts.endTime.Sub(t.ts.startTime).Seconds()))
|
||||||
|
}
|
||||||
|
for _, a := range t.ts.attributes {
|
||||||
|
out.WriteString(" ")
|
||||||
|
out.WriteString(a.Key)
|
||||||
|
out.WriteString("=")
|
||||||
|
value := a.Value.AsString()
|
||||||
|
if strings.ContainsAny(value, " \t\r\n") {
|
||||||
|
quoted := false
|
||||||
|
for _, c := range "\"'`" {
|
||||||
|
if quoted = !strings.Contains(value, string(c)); quoted {
|
||||||
|
value = string(c) + value + string(c)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !quoted {
|
||||||
|
value = fmt.Sprintf("%q", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.WriteString(value)
|
||||||
|
}
|
||||||
|
out.WriteString("\n")
|
||||||
|
for _, c := range t.ts.children {
|
||||||
|
span := c.internalSpans[t.internalSpanIdx].(*traceBuiltinSpan)
|
||||||
|
span.toString(out, indent+2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *traceBuiltinSpan) end() {
|
||||||
|
if t.ts.parent == nil {
|
||||||
|
// TODO: debug purpose only
|
||||||
|
// TODO: it should distinguish between http response network lag and actual processing time
|
||||||
|
threshold := time.Duration(traceBuiltinThreshold.Load())
|
||||||
|
if threshold != 0 && t.ts.endTime.Sub(t.ts.startTime) > threshold {
|
||||||
|
sb := &strings.Builder{}
|
||||||
|
t.toString(sb, 0)
|
||||||
|
tailmsg.GetManager().GetTraceRecorder().Record(sb.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *traceBuiltinStarter) start(ctx context.Context, traceSpan *TraceSpan, internalSpanIdx int) (context.Context, traceSpanInternal) {
|
||||||
|
return ctx, &traceBuiltinSpan{ts: traceSpan, internalSpanIdx: internalSpanIdx}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
globalTraceStarters = append(globalTraceStarters, &traceBuiltinStarter{})
|
||||||
|
}
|
||||||
|
|
||||||
|
var traceBuiltinThreshold atomic.Int64
|
||||||
|
|
||||||
|
func EnableBuiltinTracer(threshold time.Duration) {
|
||||||
|
traceBuiltinThreshold.Store(int64(threshold))
|
||||||
|
}
|
@ -0,0 +1,19 @@
|
|||||||
|
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package gtprof
|
||||||
|
|
||||||
|
// Some interesting names could be found in https://github.com/open-telemetry/opentelemetry-go/tree/main/semconv
|
||||||
|
|
||||||
|
const (
|
||||||
|
TraceSpanHTTP = "http"
|
||||||
|
TraceSpanGitRun = "git-run"
|
||||||
|
TraceSpanDatabase = "database"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
TraceAttrFuncCaller = "func.caller"
|
||||||
|
TraceAttrDbSQL = "db.sql"
|
||||||
|
TraceAttrGitCommand = "git.command"
|
||||||
|
TraceAttrHTTPRoute = "http.route"
|
||||||
|
)
|
@ -0,0 +1,93 @@
|
|||||||
|
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package gtprof
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// "vendor span" is a simple demo for a span from a vendor library
|
||||||
|
|
||||||
|
var vendorContextKey any = "vendorContextKey"
|
||||||
|
|
||||||
|
type vendorSpan struct {
|
||||||
|
name string
|
||||||
|
children []*vendorSpan
|
||||||
|
}
|
||||||
|
|
||||||
|
func vendorTraceStart(ctx context.Context, name string) (context.Context, *vendorSpan) {
|
||||||
|
span := &vendorSpan{name: name}
|
||||||
|
parentSpan, ok := ctx.Value(vendorContextKey).(*vendorSpan)
|
||||||
|
if ok {
|
||||||
|
parentSpan.children = append(parentSpan.children, span)
|
||||||
|
}
|
||||||
|
ctx = context.WithValue(ctx, vendorContextKey, span)
|
||||||
|
return ctx, span
|
||||||
|
}
|
||||||
|
|
||||||
|
// below "testTrace*" integrate the vendor span into our trace system
|
||||||
|
|
||||||
|
type testTraceSpan struct {
|
||||||
|
vendorSpan *vendorSpan
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *testTraceSpan) addEvent(name string, cfg *EventConfig) {}
|
||||||
|
|
||||||
|
func (t *testTraceSpan) recordError(err error, cfg *EventConfig) {}
|
||||||
|
|
||||||
|
func (t *testTraceSpan) end() {}
|
||||||
|
|
||||||
|
type testTraceStarter struct{}
|
||||||
|
|
||||||
|
func (t *testTraceStarter) start(ctx context.Context, traceSpan *TraceSpan, internalSpanIdx int) (context.Context, traceSpanInternal) {
|
||||||
|
ctx, span := vendorTraceStart(ctx, traceSpan.name)
|
||||||
|
return ctx, &testTraceSpan{span}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTraceStarter(t *testing.T) {
|
||||||
|
globalTraceStarters = []traceStarter{&testTraceStarter{}}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
ctx, span := GetTracer().Start(ctx, "root")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
func(ctx context.Context) {
|
||||||
|
ctx, span := GetTracer().Start(ctx, "span1")
|
||||||
|
defer span.End()
|
||||||
|
func(ctx context.Context) {
|
||||||
|
_, span := GetTracer().Start(ctx, "spanA")
|
||||||
|
defer span.End()
|
||||||
|
}(ctx)
|
||||||
|
func(ctx context.Context) {
|
||||||
|
_, span := GetTracer().Start(ctx, "spanB")
|
||||||
|
defer span.End()
|
||||||
|
}(ctx)
|
||||||
|
}(ctx)
|
||||||
|
|
||||||
|
func(ctx context.Context) {
|
||||||
|
_, span := GetTracer().Start(ctx, "span2")
|
||||||
|
defer span.End()
|
||||||
|
}(ctx)
|
||||||
|
|
||||||
|
var spanFullNames []string
|
||||||
|
var collectSpanNames func(parentFullName string, s *vendorSpan)
|
||||||
|
collectSpanNames = func(parentFullName string, s *vendorSpan) {
|
||||||
|
fullName := parentFullName + "/" + s.name
|
||||||
|
spanFullNames = append(spanFullNames, fullName)
|
||||||
|
for _, c := range s.children {
|
||||||
|
collectSpanNames(fullName, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
collectSpanNames("", span.internalSpans[0].(*testTraceSpan).vendorSpan)
|
||||||
|
assert.Equal(t, []string{
|
||||||
|
"/root",
|
||||||
|
"/root/span1",
|
||||||
|
"/root/span1/spanA",
|
||||||
|
"/root/span1/spanB",
|
||||||
|
"/root/span2",
|
||||||
|
}, spanFullNames)
|
||||||
|
}
|
@ -0,0 +1,73 @@
|
|||||||
|
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package tailmsg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MsgRecord struct {
|
||||||
|
Time time.Time
|
||||||
|
Content string
|
||||||
|
}
|
||||||
|
|
||||||
|
type MsgRecorder interface {
|
||||||
|
Record(content string)
|
||||||
|
GetRecords() []*MsgRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
type memoryMsgRecorder struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
msgs []*MsgRecord
|
||||||
|
limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: use redis for a clustered environment
|
||||||
|
|
||||||
|
func (m *memoryMsgRecorder) Record(content string) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.msgs = append(m.msgs, &MsgRecord{
|
||||||
|
Time: time.Now(),
|
||||||
|
Content: content,
|
||||||
|
})
|
||||||
|
if len(m.msgs) > m.limit {
|
||||||
|
m.msgs = m.msgs[len(m.msgs)-m.limit:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryMsgRecorder) GetRecords() []*MsgRecord {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
ret := make([]*MsgRecord, len(m.msgs))
|
||||||
|
copy(ret, m.msgs)
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMsgRecorder(limit int) MsgRecorder {
|
||||||
|
return &memoryMsgRecorder{
|
||||||
|
limit: limit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Manager struct {
|
||||||
|
traceRecorder MsgRecorder
|
||||||
|
logRecorder MsgRecorder
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) GetTraceRecorder() MsgRecorder {
|
||||||
|
return m.traceRecorder
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) GetLogRecorder() MsgRecorder {
|
||||||
|
return m.logRecorder
|
||||||
|
}
|
||||||
|
|
||||||
|
var GetManager = sync.OnceValue(func() *Manager {
|
||||||
|
return &Manager{
|
||||||
|
traceRecorder: NewMsgRecorder(100),
|
||||||
|
logRecorder: NewMsgRecorder(1000),
|
||||||
|
}
|
||||||
|
})
|
@ -0,0 +1,18 @@
|
|||||||
|
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/tailmsg"
|
||||||
|
"code.gitea.io/gitea/services/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
func PerfTrace(ctx *context.Context) {
|
||||||
|
monitorTraceCommon(ctx)
|
||||||
|
ctx.Data["PageIsAdminMonitorPerfTrace"] = true
|
||||||
|
ctx.Data["PerfTraceRecords"] = tailmsg.GetManager().GetTraceRecorder().GetRecords()
|
||||||
|
ctx.HTML(http.StatusOK, tplPerfTrace)
|
||||||
|
}
|
@ -0,0 +1,13 @@
|
|||||||
|
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin monitor")}}
|
||||||
|
|
||||||
|
<div class="admin-setting-content">
|
||||||
|
{{template "admin/trace_tabs" .}}
|
||||||
|
|
||||||
|
{{range $record := .PerfTraceRecords}}
|
||||||
|
<div class="ui segment tw-w-full tw-overflow-auto">
|
||||||
|
<pre class="tw-whitespace-pre">{{$record.Content}}</pre>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{template "admin/layout_footer" .}}
|
@ -0,0 +1,19 @@
|
|||||||
|
<div class="flex-text-block">
|
||||||
|
<div class="tw-flex-1">
|
||||||
|
<div class="ui compact small menu">
|
||||||
|
{{if .ShowAdminPerformanceTraceTab}}
|
||||||
|
<a class="item {{Iif .PageIsAdminMonitorPerfTrace "active"}}" href="{{AppSubUrl}}/-/admin/monitor/perftrace">{{ctx.Locale.Tr "admin.monitor.performance_logs"}}</a>
|
||||||
|
{{end}}
|
||||||
|
<a class="item {{Iif (eq .ShowGoroutineList "process") "active"}}" href="{{AppSubUrl}}/-/admin/monitor/stacktrace?show=process">{{ctx.Locale.Tr "admin.monitor.process"}}</a>
|
||||||
|
<a class="item {{Iif (eq .ShowGoroutineList "stacktrace") "active"}}" href="{{AppSubUrl}}/-/admin/monitor/stacktrace?show=stacktrace">{{ctx.Locale.Tr "admin.monitor.stacktrace"}}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form target="_blank" action="{{AppSubUrl}}/-/admin/monitor/diagnosis" class="ui form">
|
||||||
|
<div class="ui inline field">
|
||||||
|
<button class="ui primary small button">{{ctx.Locale.Tr "admin.monitor.download_diagnosis_report"}}</button>
|
||||||
|
<input name="seconds" size="3" maxlength="3" value="10"> {{ctx.Locale.Tr "tool.raw_seconds"}}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
Loading…
Reference in New Issue