mirror of https://github.com/go-gitea/gitea.git
Merge branch 'main' into Badge
commit
4890a15467
@ -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),
|
||||
}
|
||||
})
|
File diff suppressed because it is too large
Load Diff
@ -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,71 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package context
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type testAccessLoggerMock struct {
|
||||
logs []string
|
||||
}
|
||||
|
||||
func (t *testAccessLoggerMock) Log(skip int, level log.Level, format string, v ...any) {
|
||||
t.logs = append(t.logs, fmt.Sprintf(format, v...))
|
||||
}
|
||||
|
||||
func (t *testAccessLoggerMock) GetLevel() log.Level {
|
||||
return log.INFO
|
||||
}
|
||||
|
||||
type testAccessLoggerResponseWriterMock struct{}
|
||||
|
||||
func (t testAccessLoggerResponseWriterMock) Header() http.Header {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t testAccessLoggerResponseWriterMock) Before(f func(ResponseWriter)) {}
|
||||
|
||||
func (t testAccessLoggerResponseWriterMock) WriteHeader(statusCode int) {}
|
||||
|
||||
func (t testAccessLoggerResponseWriterMock) Write(bytes []byte) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (t testAccessLoggerResponseWriterMock) Flush() {}
|
||||
|
||||
func (t testAccessLoggerResponseWriterMock) WrittenStatus() int {
|
||||
return http.StatusOK
|
||||
}
|
||||
|
||||
func (t testAccessLoggerResponseWriterMock) WrittenSize() int {
|
||||
return 123123
|
||||
}
|
||||
|
||||
func TestAccessLogger(t *testing.T) {
|
||||
setting.Log.AccessLogTemplate = `{{.Ctx.RemoteHost}} - {{.Identity}} {{.Start.Format "[02/Jan/2006:15:04:05 -0700]" }} "{{.Ctx.Req.Method}} {{.Ctx.Req.URL.RequestURI}} {{.Ctx.Req.Proto}}" {{.ResponseWriter.Status}} {{.ResponseWriter.Size}} "{{.Ctx.Req.Referer}}" "{{.Ctx.Req.UserAgent}}"`
|
||||
recorder := newAccessLogRecorder()
|
||||
mockLogger := &testAccessLoggerMock{}
|
||||
recorder.logger = mockLogger
|
||||
req := &http.Request{
|
||||
RemoteAddr: "remote-addr",
|
||||
Method: "GET",
|
||||
Proto: "https",
|
||||
URL: &url.URL{Path: "/path"},
|
||||
}
|
||||
req.Header = http.Header{}
|
||||
req.Header.Add("Referer", "referer")
|
||||
req.Header.Add("User-Agent", "user-agent")
|
||||
recorder.record(time.Date(2000, 1, 2, 3, 4, 5, 0, time.UTC), &testAccessLoggerResponseWriterMock{}, req)
|
||||
assert.Equal(t, []string{`remote-addr - - [02/Jan/2000:03:04:05 +0000] "GET /path https" 200 123123 "referer" "user-agent"`}, mockLogger.logs)
|
||||
}
|
@ -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>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue