mirror of https://github.com/go-gitea/gitea
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