Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions lib/api/echo/basicauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import (
"github.com/labstack/echo/v5/middleware"
)

type User string
type Password string
type (
User string
Password string
)

type RestUsers struct {
RestUser map[User]Password `long:"http-rest-user" env:"HTTP_REST_USER" description:"Usernames for REST API access."`
Expand Down
6 changes: 4 additions & 2 deletions lib/events/avro/serde.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ import (
confluent "github.com/confluentinc/confluent-kafka-go/v2/schemaregistry"
)

var ErrPayloadToShort = errors.New("payload too short")
var ErrInvalidMagicByte = errors.New("invalid magic byte value")
var (
ErrPayloadToShort = errors.New("payload too short")
ErrInvalidMagicByte = errors.New("invalid magic byte value")
)

type Event interface {
// Schema returns the avro schema of this event
Expand Down
6 changes: 4 additions & 2 deletions lib/events/event-topics.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ import (
// TopicsFunc builds an EventTopics instance for the given kafka replication factor.
type TopicsFunc func(replicationFactor int16) EventTopics

type Topics = startup_kafka.Topics
type Topic = startup_kafka.Topic
type (
Topics = startup_kafka.Topics
Topic = startup_kafka.Topic
)

// EventTopics contains a mapping from event struct type to a kafka topic.
// This map must contain all event types that are going to be send. If this misses an event,
Expand Down
127 changes: 127 additions & 0 deletions lib/history/render.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package history

import (
"bytes"
"context"
"embed"
"encoding/json"
"fmt"
"html/template"
"io"
"sort"
"time"

"github.com/flachnetz/startup/v2/lib/ql"
)

//go:embed templates/history.gohtml
var templateFS embed.FS

//go:embed templates/overview.gohtml
var overviewFS embed.FS

var pageTemplate = template.Must(template.New("history.gohtml").
Funcs(template.FuncMap{
"formatTime": func(t time.Time) string { return t.Format("2006-01-02 15:04:05.000") },
}).
ParseFS(templateFS, "templates/history.gohtml"))

var overviewTemplate = template.Must(template.New("overview.gohtml").
ParseFS(overviewFS, "templates/overview.gohtml"))

// OverviewModel is the template model for a clickable list page linking to
// per-item detail pages.
type OverviewModel struct {
Title string
Headers []string
Rows []OverviewRow
}

// OverviewRow is one list entry; Cells aligns with OverviewModel.Headers and
// Link is the detail-page URL the row navigates to.
type OverviewRow struct {
Link string
Cells []string
}

// RenderOverview writes a standalone clickable table; each row links to Link.
func RenderOverview(w io.Writer, title string, headers []string, rows []OverviewRow) error {
if err := overviewTemplate.Execute(w, OverviewModel{Title: title, Headers: headers, Rows: rows}); err != nil {
return fmt.Errorf("render overview: %w", err)
}
return nil
}

// RecordView wraps a Record with its pretty-printed JSON payload for rendering.
type RecordView struct {
Record
// JSON is the indented payload.
JSON string
// ShowSeparator is true when this record starts a new RequestTraceId group.
ShowSeparator bool
}

// PageModel is the template model for the generic history page.
type PageModel struct {
Title string
GroupId string
ErrorMessage string
Summary []SummaryItem
Records []RecordView
}

// SummaryItem is one label/value row shown above the ledger, describing the
// current state of the tracked object. Ordered slice (not a map) so the page
// renders stably.
type SummaryItem struct {
Label string
Value string
}

// RenderPage writes a standalone HTML history page for groupId to w. Records are
// loaded in a new read transaction, sorted by Timestamp (Service.Records is
// unordered), and an <hr> separates consecutive RequestTraceId groups.
//
// ponytail: payload is rendered as pretty JSON only; add a key/value table when
// an item needs structured display.
func (h *Service) RenderPage(ctx context.Context, w io.Writer, groupId GroupId, title string) error {
return h.RenderPageSummary(ctx, w, groupId, title, nil)
}

// RenderPageSummary is RenderPage with an extra current-state summary rendered
// above the ledger.
func (h *Service) RenderPageSummary(ctx context.Context, w io.Writer, groupId GroupId, title string, summary []SummaryItem) error {
records, err := ql.InNewTransactionWithResult(ctx, h.txStarter, func(ctx ql.TxContext) ([]Record, error) {
return h.Records(ctx, groupId)
})
if err != nil {
return fmt.Errorf("load records: %w", err)
}

sort.SliceStable(records, func(i, j int) bool {
return records[i].Timestamp.Before(records[j].Timestamp)
})

views := make([]RecordView, len(records))
for i, rec := range records {
var pretty bytes.Buffer
if err := json.Indent(&pretty, rec.Payload, "", " "); err != nil {
// keep the raw payload if it is not valid JSON.
pretty.Reset()
pretty.Write(rec.Payload)
}

views[i] = RecordView{
Record: rec,
JSON: pretty.String(),
ShowSeparator: i > 0 && rec.RequestTraceId.String() != records[i-1].RequestTraceId.String(),
}
}

return pageTemplate.Execute(w, PageModel{
Title: title,
GroupId: groupId.String(),
Summary: summary,
Records: views,
})
}
22 changes: 21 additions & 1 deletion lib/history/singleton.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package history

import (
"context"
"errors"
"fmt"
"io"
"log/slog"

"github.com/flachnetz/startup/v2/lib/events"
Expand Down Expand Up @@ -34,7 +36,6 @@ func InitializeGlobal(ctx context.Context, opts Options) error {
err := ql.InNewTransaction(ctx, opts.DB, func(ctx ql.TxContext) error {
return CreateTable(ctx, opts.HistoryTable)
})

if err != nil {
return fmt.Errorf("create history table: %w", err)
}
Expand Down Expand Up @@ -65,3 +66,22 @@ func Track[T ~string](ctx context.Context, groupId T, item Item) {

instance.Track(ctx, GroupId(groupId), item)
}

// RenderPage uses the global history singleton to render the history page for
// groupId. You need to initialize it using InitializeGlobal first.
func RenderPage[T ~string](ctx context.Context, w io.Writer, groupId T, title string) error {
if instance == nil {
return errors.New("history: global instance not initialized")
}

return instance.RenderPage(ctx, w, GroupId(groupId), title)
}

// RenderPageSummary is RenderPage with a current-state summary above the ledger.
func RenderPageSummary[T ~string](ctx context.Context, w io.Writer, groupId T, title string, summary []SummaryItem) error {
if instance == nil {
return errors.New("history: global instance not initialized")
}

return instance.RenderPageSummary(ctx, w, GroupId(groupId), title, summary)
}
55 changes: 55 additions & 0 deletions lib/history/templates/history.gohtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ .Title }}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/sakura.css/css/sakura.css" type="text/css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release/build/styles/default.min.css">
<style>
body { max-width: 60em; }
dt { font-weight: bold; margin-top: 0.5em; }
.meta { color: #666; font-size: 0.85em; font-family: monospace; }
details > summary { cursor: pointer; }
pre { margin: 0.3em 0; }
hr { border: none; border-top: 2px solid #ddd; margin: 1.5em 0; }
.summary { background: #f4f6f8; border: 1px solid #d7dde3; border-radius: 8px;
padding: 1em 1.25em; margin: 1em 0 2em; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.4em 2em; }
.summary div { display: flex; gap: 0.6em; align-items: baseline;
border-bottom: 1px solid #e3e8ec; padding: 0.25em 0; }
.summary span.k { font-weight: bold; min-width: 9em; color: #444; }
.summary span.v { font-family: monospace; word-break: break-all; }
.ledger > div { border-top: 1px solid #e3e8ec; padding: 0.75em 0; }
.ledger .step { font-weight: bold; font-size: 1.05em; }
</style>
</head>
<body>
<h1>{{ .Title }}</h1>
<p class="meta">GroupId: {{ .GroupId }}</p>
{{ if .Summary }}
<div class="summary">
{{ range .Summary }}<div><span class="k">{{ .Label }}</span><span class="v">{{ .Value }}</span></div>{{ end }}
</div>
{{ end }}
{{ if .ErrorMessage }}<p style="color:red">{{ .ErrorMessage }}</p>{{ end }}
<div class="ledger">
{{ range .Records }}
{{ if .ShowSeparator }}<hr>{{ end }}
<div>
<p class="step">{{ .Step }}</p>
<p>{{ .Description }}</p>
<p class="meta">{{ formatTime .Timestamp }} · TraceId: {{ .RequestTraceId }} · EventSender: {{ .EventSender }}{{ if .EventSenderVersion }} ({{ .EventSenderVersion }}){{ end }}</p>
<details>
<summary>payload</summary>
<pre><code class="language-json">{{ .JSON }}</code></pre>
</details>
</div>
{{ else }}
<p>No history records.</p>
{{ end }}
</div>
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release/build/highlight.min.js"></script>
<script>hljs.highlightAll();</script>
</body>
</html>
29 changes: 29 additions & 0 deletions lib/history/templates/overview.gohtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{{ .Title }}</title>
<style>
body { max-width: 80em; margin: 2em auto; font-family: sans-serif; }
table { border-collapse: collapse; width: 100%; }
th, td { text-align: left; padding: 6px 12px; border-bottom: 1px solid #e3e8ec; }
tr.row { cursor: pointer; }
tr.row:hover { background: #f4f6f8; }
td { font-family: monospace; }
a { text-decoration: none; }
</style>
</head>
<body>
<h1>{{ .Title }}</h1>
<table>
<tr>{{ range .Headers }}<th>{{ . }}</th>{{ end }}</tr>
{{ range .Rows }}
<tr class="row" onclick="location='{{ .Link }}'">
{{ range .Cells }}<td>{{ . }}</td>{{ end }}
</tr>
{{ else }}
<tr><td>No records.</td></tr>
{{ end }}
</table>
</body>
</html>
1 change: 0 additions & 1 deletion lib/kconsumer/consumer.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,6 @@ func runWorker(ctx context.Context, w *partitionWorker, handle HandleMessage) {

return
})

if err != nil {
log.Error("Giving up on message", slog.Int64("offset", int64(offset)))
w.errCh <- err
Expand Down
2 changes: 1 addition & 1 deletion startup_kafka/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ func (opts *KafkaOptions) NewConsumer(overrideConfig kafka.ConfigMap) *kafka.Con

// Resolve the special RANDOM group into a unique group id once, so repeated
// calls keep using the same generated group.
var consumerGroup = opts.KafkaConsumerGroup
consumerGroup := opts.KafkaConsumerGroup
if consumerGroup == "RANDOM" {
consumerGroup = fmt.Sprintf("golang-%d", time.Now().UnixNano())
}
Expand Down