package main
import (
"encoding/binary"
"fmt"
"github.com/google/uuid"
"github.com/linkedin/goavro/v2"
"ktea/config"
"ktea/kadmin"
"ktea/sradmin"
"math/big"
"strconv"
"sync"
"time"
)
type eventGenFunc func(id string) interface{}
type generationData struct {
topic string
subject string
schema []string
eventGenFunc
}
func main() {
genData := []generationData{
{"dev.finance.invoice",
"dev.finance.invoice-io.jonasg.ktea.invoice.InvoiceCreated",
[]string{`
{
"type": "record",
"name": "InvoiceCreated",
"namespace": "io.jonasg.ktea.invoice",
"doc": "Schema for the InvoiceCreated event.",
"fields": [
{"name": "id", "type": "string", "doc": "Unique identifier for the invoice."},
{"name": "customerId", "type": "string", "doc": "Unique identifier for the customer."},
{"name": "amount", "type": "bytes", "logicalType": "decimal", "precision": 4, "scale": 2, "doc": "Total amount of the invoice."},
{"name": "currency", "type": "string", "doc": "Currency of the invoice amount."},
{"name": "issueDate", "type": "string", "doc": "Date when the invoice was issued, in ISO 8601 format."},
{"name": "dueDate", "type": "string", "doc": "Date when the invoice is due, in ISO 8601 format."},
{"name": "status", "type": "string", "doc": "Current status of the invoice (e.g., 'Paid', 'Pending')."},
{"name": "description", "type": "string", "doc": "Description or notes about the invoice."}
]
}
`},
func(id string) interface{} {
return map[string]interface{}{
"id": id,
"customerId": uuid.New().String(),
"amount": big.NewRat(100, 100),
"currency": "USD",
"issueDate": time.Now().Format(time.RFC3339),
"dueDate": time.Now().AddDate(0, 0, 30).Format(time.RFC3339),
"status": "Pending",
"description": "Invoice for services rendered.",
}
},
},
{
"dev.finance.payment",
"dev.finance.payment-io.jonasg.ktea.payment.PaymentProcessed",
[]string{`
{
"type": "record",
"name": "PaymentProcessed",
"namespace": "io.jonasg.ktea.payment",
"doc": "Schema for the PaymentProcessed event.",
"fields": [
{"name": "id", "type": "string", "doc": "Unique identifier for the payment."},
{"name": "invoiceId", "type": "string", "doc": "Unique identifier for the associated invoice."},
{"name": "amount", "type": "bytes", "logicalType": "decimal", "precision": 4, "scale": 2, "doc": "Amount of the payment."},
{"name": "currency", "type": "string", "doc": "Currency of the payment amount."},
{"name": "paymentDate", "type": "string", "doc": "Date when the payment was made, in ISO 8601 format."},
{"name": "status", "type": "string", "doc": "Current status of the payment (e.g., 'Completed', 'Failed')."},
{"name": "method", "type": "string", "doc": "Payment method used (e.g., 'Credit Card', 'Bank Transfer')."}
]
}
`},
func(id string) interface{} {
return map[string]interface{}{
"id": id,
"invoiceId": uuid.New().String(),
"amount": big.NewRat(500, 100),
"currency": "USD",
"paymentDate": time.Now().Format(time.RFC3339),
"status": "Completed",
"method": "Credit Card",
}
},
},
{
"dev.order.checkout",
"dev.order.checkout-io.jonasg.ktea.order.CheckoutInitiated",
[]string{`
{
"type": "record",
"name": "CheckoutInitiated",
"namespace": "io.jonasg.ktea.order",
"doc": "Schema for the CheckoutInitiated event.",
"fields": [
{"name": "id", "type": "string", "doc": "Unique identifier for the checkout."},
{"name": "cartId", "type": "string", "doc": "Unique identifier for the cart."},
{"name": "totalAmount", "type": "bytes", "logicalType": "decimal", "precision": 10, "scale": 2, "doc": "Total amount for the checkout."},
{"name": "currency", "type": "string", "doc": "Currency of the total amount."},
{"name": "checkoutDate", "type": "string", "doc": "Date when the checkout was initiated, in ISO 8601 format."}
]
}
`},
func(id string) interface{} {
return map[string]interface{}{
"id": id,
"cartId": uuid.New().String(),
"totalAmount": big.NewRat(1500, 100),
"currency": "USD",
"checkoutDate": time.Now().Format(time.RFC3339),
}
},
},
{
"dev.order.shipment",
"dev.order.shipment-io.jonasg.ktea.order.ShipmentCreated",
[]string{`
{
"type": "record",
"name": "ShipmentCreated",
"namespace": "io.jonasg.ktea.order",
"doc": "Schema for the ShipmentCreated event.",
"fields": [
{"name": "id", "type": "string", "doc": "Unique identifier for the shipment."},
{"name": "orderId", "type": "string", "doc": "Unique identifier for the order."},
{"name": "shipmentDate", "type": "string", "doc": "Date when the shipment was created, in ISO 8601 format."},
{"name": "carrier", "type": "string", "doc": "Carrier responsible for the shipment."},
{"name": "trackingNumber", "type": "string", "doc": "Tracking number for the shipment."}
]
}
`},
func(id string) interface{} {
return map[string]interface{}{
"id": id,
"orderId": uuid.New().String(),
"shipmentDate": time.Now().Format(time.RFC3339),
"carrier": "FedEx",
"trackingNumber": "123456789",
}
},
},
{
"dev.product.stock",
"dev.product.stock-io.jonasg.ktea.product.StockUpdated",
[]string{`
{
"type": "record",
"name": "StockUpdated",
"namespace": "io.jonasg.ktea.product",
"doc": "Schema for the StockUpdated event.",
"fields": [
{"name": "id", "type": "string", "doc": "Unique identifier for the stock update."},
{"name": "productId", "type": "string", "doc": "Unique identifier for the product."},
{"name": "quantity", "type": "int", "doc": "Quantity of the product in stock."},
{"name": "updateDate", "type": "string", "doc": "Date when the stock was updated, in ISO 8601 format."}
]
}
`},
func(id string) interface{} {
return map[string]interface{}{
"id": id,
"productId": uuid.New().String(),
"quantity": 100,
"updateDate": time.Now().Format(time.RFC3339),
}
},
},
{
"dev.product.category",
"dev.product.category-io.jonasg.ktea.product.CategoryAssigned",
[]string{`
{
"type": "record",
"name": "CategoryAssigned",
"namespace": "io.jonasg.ktea.product",
"doc": "Schema for the CategoryAssigned event.",
"fields": [
{"name": "id", "type": "string", "doc": "Unique identifier for the category assignment."},
{"name": "productId", "type": "string", "doc": "Unique identifier for the product."},
{"name": "category", "type": "string", "doc": "Category assigned to the product."},
{"name": "assignmentDate", "type": "string", "doc": "Date when the category was assigned, in ISO 8601 format."}
]
}
`},
func(id string) interface{} {
return map[string]interface{}{
"id": id,
"productId": uuid.New().String(),
"category": "Electronics",
"assignmentDate": time.Now().Format(time.RFC3339),
}
},
},
{
"dev.product.price",
"dev.product.price-io.jonasg.ktea.product.PriceUpdated",
[]string{`
{
"type": "record",
"name": "PriceUpdated",
"namespace": "io.jonasg.ktea.product",
"doc": "Schema for the PriceUpdated event.",
"fields": [
{"name": "id", "type": "string", "doc": "Unique identifier for the price update."},
{"name": "productId", "type": "string", "doc": "Unique identifier for the product."},
{"name": "price", "type": "bytes", "logicalType": "decimal", "precision": 10, "scale": 2, "doc": "Updated price of the product."},
{"name": "currency", "type": "string", "doc": "Currency of the price."},
{"name": "updateDate", "type": "string", "doc": "Date when the price was updated, in ISO 8601 format."}
]
}
`},
func(id string) interface{} {
return map[string]interface{}{
"id": id,
"productId": uuid.New().String(),
"price": big.NewRat(2000, 100),
"currency": "USD",
"updateDate": time.Now().Format(time.RFC3339),
}
},
},
{
"dev.customer.profile",
"dev.customer.profile-io.jonasg.ktea.customer.ProfileUpdated",
[]string{`
{
"type": "record",
"name": "ProfileUpdated",
"namespace": "io.jonasg.ktea.customer",
"doc": "Schema for the ProfileUpdated event.",
"fields": [
{"name": "id", "type": "string", "doc": "Unique identifier for the profile update."},
{"name": "customerId", "type": "string", "doc": "Unique identifier for the customer."},
{"name": "updateDate", "type": "string", "doc": "Date when the profile was updated, in ISO 8601 format."},
{"name": "changes", "type": "string", "doc": "Description of the changes made to the profile."}
]
}
`},
func(id string) interface{} {
return map[string]interface{}{
"id": id,
"customerId": uuid.New().String(),
"updateDate": time.Now().Format(time.RFC3339),
"changes": "Updated email address and phone number.",
}
},
},
{
"dev.customer.action",
"dev.customer.action-io.jonasg.ktea.customer.ActionLogged",
[]string{
`
{
"type": "record",
"name": "ActionLogged",
"namespace": "io.jonasg.ktea.customer",
"doc": "Schema for the ActionLogged event.",
"fields": [
{"name": "id", "type": "string", "doc": "Unique identifier for the action log."},
{"name": "customerId", "type": "string", "doc": "Unique identifier for the customer."},
{"name": "origin", "type": "string", "doc": "Original of the action."},
{"name": "platform", "type": "string", "doc": "Platform from which the action was performed."},
{"name": "action", "type": "string", "doc": "Description of the action performed by the customer."},
{"name": "actionDate", "type": "string", "doc": "Date when the action was performed, in ISO 8601 format."}
]
}
`,
`
{
"type": "record",
"name": "ActionLogged",
"namespace": "io.jonasg.ktea.customer",
"doc": "Schema for the ActionLogged event.",
"fields": [
{"name": "id", "type": "string", "doc": "Unique identifier for the action log."},
{"name": "customerId", "type": "string", "doc": "Unique identifier for the customer."},
{"name": "origin", "type": "string", "doc": "Original of the action."},
{"name": "action", "type": "string", "doc": "Description of the action performed by the customer."},
{"name": "actionDate", "type": "string", "doc": "Date when the action was performed, in ISO 8601 format."}
]
}
`,
`
{
"type": "record",
"name": "ActionLogged",
"namespace": "io.jonasg.ktea.customer",
"doc": "Schema for the ActionLogged event.",
"fields": [
{"name": "id", "type": "string", "doc": "Unique identifier for the action log."},
{"name": "customerId", "type": "string", "doc": "Unique identifier for the customer."},
{"name": "action", "type": "string", "doc": "Description of the action performed by the customer."},
{"name": "actionDate", "type": "string", "doc": "Date when the action was performed, in ISO 8601 format."}
]
}
`},
func(id string) interface{} {
return map[string]interface{}{
"id": id,
"customerId": uuid.New().String(),
"action": "Logged in to the system.",
"actionDate": time.Now().Format(time.RFC3339),
}
},
},
{
"dev.customer.feedback",
"dev.customer.feedback-io.jonasg.ktea.customer.FeedbackReceived",
[]string{`
{
"type": "record",
"name": "FeedbackReceived",
"namespace": "io.jonasg.ktea.customer",
"doc": "Schema for the FeedbackReceived event.",
"fields": [
{"name": "id", "type": "string", "doc": "Unique identifier for the feedback."},
{"name": "customerId", "type": "string", "doc": "Unique identifier for the customer."},
{"name": "feedback", "type": "string", "doc": "Content of the feedback provided by the customer."},
{"name": "feedbackDate", "type": "string", "doc": "Date when the feedback was provided, in ISO 8601 format."}
]
}
`},
func(id string) interface{} {
return map[string]interface{}{
"id": id,
"customerId": uuid.New().String(),
"feedback": "Great service!",
"feedbackDate": time.Now().Format(time.RFC3339),
}
},
},
}
ka, sa := getAdmins()
wg := sync.WaitGroup{}
wg.Add(len(genData))
for _, gd := range genData {
go func() {
defer wg.Done()
if !topicExists(ka, gd.topic) {
createTopic(ka, gd.topic)
}
if !subjectExists(sa, gd.subject) {
for _, s := range gd.schema {
registerSchema(sa, gd.subject, s)
}
}
schemaInfo := getLatestSchema(sa, gd.subject)
for i := 0; i < 1000; i++ {
id := uuid.New().String()
event := gd.eventGenFunc(id)
publish(ka, gd.topic, id, event, schemaInfo)
}
fmt.Printf("Published 10.000 events to topic %s with subject %s\n", gd.topic, gd.subject)
}()
}
wg.Wait()
}
func publish(ka kadmin.Kadmin, topic string, id string, event interface{}, schemaInfo sradmin.Schema) {
//personJson, _ := json.Marshal(event)
codec, _ := goavro.NewCodec(schemaInfo.Value)
valueBytes, err := codec.BinaryFromNative(nil, event)
if err != nil {
panic(fmt.Sprintf("Failed to convert JSON to native Avro: %v", err))
}
//valueBytes, _ := codec.BinaryFromNative(nil, native)
schemaId, err := strconv.Atoi(schemaInfo.Id)
if err != nil {
panic(fmt.Sprintf("Failed to convert schema ID to bytes: %v", err))
}
schemaIDBytes := make([]byte, 4)
binary.BigEndian.PutUint32(schemaIDBytes, uint32(schemaId))
var record []byte
record = append(record, byte(0))
record = append(record, schemaIDBytes...)
record = append(record, valueBytes...)
msg := ka.PublishRecord(&kadmin.ProducerRecord{
Key: id,
Value: record,
Topic: topic,
Partition: nil,
Headers: map[string]string{
"content-type": "application/vnd.apache.avro+json",
"eventId": id,
"eventType": "ProductCreated",
"eventSource": "ktea",
"eventVersion": "1.0",
"eventTime": time.Now().String(),
},
})
switch msg := msg.AwaitCompletion().(type) {
case kadmin.PublicationSucceeded:
case kadmin.PublicationFailed:
panic(fmt.Sprintf("Failed to publish message %v", msg.Err))
}
}
func getLatestSchema(sa sradmin.Client, subject string) sradmin.Schema {
msg := sa.GetLatestSchemaBySubject(subject).(sradmin.FetchingLatestSchemaBySubjectMsg)
var schemaInfo sradmin.Schema
switch msg := msg.AwaitCompletion().(type) {
case sradmin.LatestSchemaBySubjectReceived:
fmt.Println("Latest schema fetched successfully for subject:", subject)
schemaInfo = msg.Schema
case sradmin.FailedToFetchLatestSchemaBySubject:
panic(fmt.Sprintf("Failed to get latest schema by subject: %v", msg.Err))
}
return schemaInfo
}
func registerSchema(srAdmin sradmin.Client, subject string, schema string) {
msg := srAdmin.CreateSchema(sradmin.SubjectCreationDetails{
Subject: subject,
Schema: schema,
}).(sradmin.SchemaCreationStartedMsg)
switch msg := msg.AwaitCompletion().(type) {
case sradmin.SchemaCreatedMsg:
fmt.Println("Schema created successfully for subject:", subject)
case sradmin.SchemaCreationErrMsg:
panic(fmt.Sprintf("Failed to create schema for subject %s: %v", subject, msg.Err))
}
}
func getAdmins() (kadmin.Kadmin, sradmin.Client) {
ka, err := kadmin.NewSaramaKadmin(kadmin.ConnectionDetails{
BootstrapServers: []string{"localhost:9092"},
SASLConfig: nil,
SSLEnabled: false,
})
if err != nil {
panic(fmt.Sprintf("Failed to create Kafka admin client: %v", err))
}
sa := sradmin.New(&config.SchemaRegistryConfig{
Url: "http://localhost:8081",
Username: "",
Password: "",
})
return ka, sa
}
func createTopic(ka kadmin.Kadmin, topic string) {
tm := ka.CreateTopic(kadmin.TopicCreationDetails{
Name: topic,
NumPartitions: 1,
ReplicationFactor: 1,
}).(kadmin.TopicCreationStartedMsg)
switch msg := tm.AwaitCompletion().(type) {
case kadmin.TopicCreatedMsg:
fmt.Printf("Topic %s created successfully", topic)
case kadmin.TopicCreationErrMsg:
panic(fmt.Sprintf("Failed to create topic: %v", msg.Err))
}
}
func topicExists(ka kadmin.Kadmin, expectedTopic string) bool {
msg := ka.ListTopics().(kadmin.TopicListingStartedMsg)
switch msg := msg.AwaitTopicListCompletion().(type) {
case kadmin.TopicsListedMsg:
topics := msg.Topics
for _, topic := range topics {
if topic.Name == expectedTopic {
fmt.Println("Topic " + expectedTopic + " already exists")
return true
}
}
case kadmin.TopicListedErrorMsg:
panic(fmt.Sprintf("Failed to list topics: %v", msg.Err))
}
return false
}
func subjectExists(srAdmin sradmin.Client, subject string) bool {
msg := srAdmin.ListSubjects().(sradmin.SubjectListingStartedMsg)
switch msg := msg.AwaitCompletion().(type) {
case sradmin.SubjectsListedMsg:
for _, s := range msg.Subjects {
if s.Name == subject {
return true
}
}
case sradmin.SubjectListingErrorMsg:
panic(fmt.Sprintf("Failed to list subjects: %v", msg.Err))
}
return false
}
package main
import (
"flag"
"ktea/config"
"ktea/kadmin"
"ktea/kcadmin"
"ktea/kontext"
"ktea/sradmin"
"ktea/ui"
"ktea/ui/components/tab"
"ktea/ui/pages/clusters_page"
"ktea/ui/tabs"
"ktea/ui/tabs/cgroups_tab"
"ktea/ui/tabs/clusters_tab"
"ktea/ui/tabs/kcon_tab"
"ktea/ui/tabs/loading_tab"
"ktea/ui/tabs/sr_tab"
"ktea/ui/tabs/topics_tab"
"os"
"slices"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/log"
)
var version string
const (
topicsTabLbl tab.Label = "topics"
cgroupsTabLbl = "cgroups"
schemaRegTabLbl = "schemaReg"
clustersTabLbl = "clusters"
kconnectTabLbl = "kconnect"
)
var topicsTab = tab.Tab{Title: "Topics", Label: topicsTabLbl}
var cgroupsTab = tab.Tab{Title: "Consumer Groups", Label: cgroupsTabLbl}
var schemaRegTab = tab.Tab{Title: "Schema Registry", Label: schemaRegTabLbl}
var kconnectTab = tab.Tab{Title: "Kafka Connect", Label: kconnectTabLbl}
var clustersTab = tab.Tab{Title: "Clusters", Label: clustersTabLbl}
type Model struct {
tabs tab.Model
activeTab tab.Tab
tabCtrl tabs.TabController
ktx *kontext.ProgramKtx
topicsTabCtrl *topics_tab.Model
cgroupsTabCtrl *cgroups_tab.Model
kaInstantiator kadmin.Instantiator
ka kadmin.Kadmin
sra sradmin.Client
renderer *ui.Renderer
schemaRegistryTabCtrl *sr_tab.Model
clustersTabCtrl *clusters_tab.Model
kconTabCtrl *kcon_tab.Model
configIO config.IO
switchingCluster bool
startupConnErr bool
}
// RetryClusterConnectionMsg is an internal Msg
// to actually retry the cluster connection
type RetryClusterConnectionMsg struct {
Cluster *config.Cluster
}
func (m *Model) Init() tea.Cmd {
return tea.Batch(func() tea.Msg {
return config.LoadedMsg{Config: config.New(m.configIO)}
}, tea.WindowSize())
}
func (m *Model) View() string {
m.ktx = kontext.WithNewAvailableDimensions(m.ktx)
if m.renderer == nil {
m.renderer = ui.NewRenderer(m.ktx)
}
var views []string
logoView := m.renderer.Render(" ___ \n |/ | _ _.\n |\\ | (/_ (_| " + version)
views = append(views, logoView)
tabsView := m.tabs.View(m.ktx, m.renderer)
views = append(views, tabsView)
if m.tabCtrl != nil {
view := m.tabCtrl.View(m.ktx, m.renderer)
views = append(views, view)
}
return ui.JoinVertical(lipgloss.Top, views...)
}
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyCtrlC:
return m, tea.Quit
}
// Make sure the events, because of their async nature,
// are explicitly captured and properly propagated
// in the case when the tabCtrl hence the page isn't focussed anymore
case kadmin.TopicsListedMsg,
kadmin.TopicListingStartedMsg:
if m.topicsTabCtrl != nil {
return m, m.topicsTabCtrl.Update(msg)
}
case kadmin.ConsumerGroupsListedMsg,
kadmin.ConsumerGroupListingStartedMsg:
return m, m.cgroupsTabCtrl.Update(msg)
case sradmin.SubjectsListedMsg,
sradmin.GlobalCompatibilityListingStartedMsg,
sradmin.GlobalCompatibilityListedMsg,
sradmin.SubjectDeletedMsg:
if m.schemaRegistryTabCtrl != nil {
return m, m.schemaRegistryTabCtrl.Update(msg)
}
case sradmin.SubjectListingStartedMsg:
if m.schemaRegistryTabCtrl != nil {
cmd := m.schemaRegistryTabCtrl.Update(msg)
cmds = append(cmds, cmd)
}
case kcadmin.ConnectorListingStartedMsg,
kcadmin.ConnectorsListedMsg,
kcadmin.ConnectorListingErrMsg:
return m, m.kconTabCtrl.Update(msg)
case kadmin.ConnCheckStartedMsg:
m.switchingCluster = true
case kadmin.ConnCheckErrMsg, kadmin.ConnCheckSucceededMsg:
m.switchingCluster = false
case config.ClusterRegisteredMsg:
// if the active cluster has been updated it needs to be reloaded
if msg.Cluster.Active {
cmd := m.boostrapUI(msg.Cluster)
cmds = append(cmds, cmd)
// keep clusters tab focussed after recreating tabs
m.tabs.GoToTab(clustersTabLbl)
m.tabCtrl = m.clustersTabCtrl
// TODO navigate to active cluster form
}
case RetryClusterConnectionMsg:
c := m.boostrapUI(msg.Cluster)
return m, c
case kadmin.ClusterConfigMsg, kadmin.ClusterConfigStartedMsg:
if m.clustersTabCtrl != nil {
return m, m.clustersTabCtrl.Update(msg)
}
case config.LoadedMsg:
m.ktx.Config = msg.Config
if m.ktx.Config.HasClusters() {
cmd := m.boostrapUI(msg.Config.ActiveCluster())
cmds = append(cmds, cmd)
m.tabs.GoToTab(topicsTabLbl)
return m, tea.Batch(cmds...)
} else {
tCtrl, cmd := clusters_tab.New(m.ktx, kadmin.CheckKafkaConnectivity, sradmin.CheckSchemaRegistryConn)
m.tabCtrl = tCtrl
m.tabs = tab.New(clustersTab)
return m, cmd
}
case clusters_page.ClusterSwitchedMsg:
cmd := m.boostrapUI(msg.Cluster)
cmds = append(cmds, cmd)
if m.startupConnErr {
m.startupConnErr = false
m.tabs.GoToTab(topicsTabLbl)
m.tabCtrl = m.topicsTabCtrl
} else {
// tabs were recreated due to cluster switch,
// make sure we stay on the clusters tab because,
// which might have introduced or removed the schema-registry tab
m.tabs.GoToTab(clustersTabLbl)
m.tabCtrl = m.clustersTabCtrl
}
case tea.WindowSizeMsg:
m.onWindowSizeUpdated(msg)
}
if !m.switchingCluster {
m.tabs.Update(msg)
if m.tabs.ActiveTab() != m.activeTab {
m.activeTab = m.tabs.ActiveTab()
switch m.activeTab.Label {
case topicsTabLbl:
m.tabCtrl = m.topicsTabCtrl
case cgroupsTabLbl:
m.tabCtrl = m.cgroupsTabCtrl
case schemaRegTabLbl:
if m.ktx.Config.ActiveCluster().HasSchemaRegistry() {
m.tabCtrl = m.schemaRegistryTabCtrl
break
}
fallthrough
case clustersTabLbl:
if m.clustersTabCtrl == nil {
var cmd tea.Cmd
m.clustersTabCtrl, cmd = clusters_tab.New(m.ktx, kadmin.CheckKafkaConnectivity, sradmin.CheckSchemaRegistryConn)
cmds = append(cmds, cmd)
}
m.tabCtrl = m.clustersTabCtrl
case kconnectTabLbl:
m.tabCtrl = m.kconTabCtrl
}
// can only be nil when ktea has not been fully loaded yet (config.LoadedMsg not been processed)
if m.tabCtrl != nil {
cmds = append(cmds, m.tabCtrl.Update(ui.RegainedFocusMsg{}))
}
}
}
if m.tabCtrl == nil {
var cmd tea.Cmd
m.tabCtrl, cmd = loading_tab.New()
cmds = append(cmds, cmd)
}
var cmd tea.Cmd
cmd = m.tabCtrl.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
func (m *Model) recreateTabs(cluster *config.Cluster) {
titles := []tab.Tab{topicsTab, cgroupsTab, clustersTab}
if cluster.HasSchemaRegistry() {
titles = slices.Insert(titles, 2, schemaRegTab)
}
if cluster.HasKafkaConnect() {
titles = slices.Insert(titles, len(titles)-1, kconnectTab)
}
m.tabs = tab.New(titles...)
}
// recreateAdminClients (re)creates the kadmin.Model and kadmin.SrAdmin
// based on the given cluster
func (m *Model) recreateAdminClients(cluster *config.Cluster) error {
connDetails := kadmin.ToConnectionDetails(cluster)
if ka, err := m.kaInstantiator(connDetails); err != nil {
return err
} else {
m.ka = ka
}
if cluster.HasSchemaRegistry() {
m.sra = sradmin.New(m.ktx.Config.ActiveCluster().SchemaRegistry)
m.ka.SetSra(m.sra)
}
return nil
}
func (m *Model) onWindowSizeUpdated(msg tea.WindowSizeMsg) {
m.ktx.WindowWidth = msg.Width
m.ktx.WindowHeight = msg.Height
m.ktx.AvailableHeight = msg.Height
}
func (m *Model) boostrapUI(cluster *config.Cluster) tea.Cmd {
var cmd tea.Cmd
if err := m.recreateAdminClients(cluster); err != nil {
m.tabs = tab.New(clustersTab)
m.clustersTabCtrl, cmd = clusters_tab.New(m.ktx, kadmin.CheckKafkaConnectivity, sradmin.CheckSchemaRegistryConn)
m.startupConnErr = true
m.tabCtrl = m.clustersTabCtrl
return tea.Batch(cmd, func() tea.Msg {
return kadmin.ConnErrMsg{
Err: err,
}
})
} else {
var cmds []tea.Cmd
m.recreateTabs(cluster)
if m.ktx.Config.ActiveCluster().HasSchemaRegistry() {
m.schemaRegistryTabCtrl, cmd = sr_tab.New(m.sra, m.ktx)
cmds = append(cmds, cmd)
}
m.cgroupsTabCtrl, cmd = cgroups_tab.New(m.ka, m.ka, m.ka)
cmds = append(cmds, cmd)
m.topicsTabCtrl, cmd = topics_tab.New(m.ktx, m.ka)
cmds = append(cmds, cmd)
m.clustersTabCtrl, cmd = clusters_tab.New(m.ktx, kadmin.CheckKafkaConnectivity, sradmin.CheckSchemaRegistryConn)
cmds = append(cmds, cmd)
m.kconTabCtrl, cmd = kcon_tab.New(m.ktx.Config.ActiveCluster())
cmds = append(cmds, cmd)
m.tabCtrl = m.topicsTabCtrl
return tea.Batch(cmds...)
}
}
func NewModel(kai kadmin.Instantiator, configIO config.IO) *Model {
return &Model{
kaInstantiator: kai,
ktx: kontext.New(),
configIO: configIO,
}
}
func main() {
var debug bool
flag.BoolVar(&debug, "debug", false, "enable debug")
flag.Parse()
p := tea.NewProgram(
NewModel(
kadmin.SaramaInstantiator(),
config.NewDefaultIO(),
),
tea.WithAltScreen(),
)
if debug {
var fileErr error
debugFile, fileErr := os.OpenFile("debug.log", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
if fileErr == nil {
log.SetOutput(debugFile)
log.SetTimeFormat(time.Kitchen)
log.SetReportCaller(true)
log.SetLevel(log.DebugLevel)
log.Debug("Logging to debug.log")
log.Info("started")
}
} else {
log.SetOutput(os.Stderr)
log.SetLevel(log.FatalLevel)
}
if _, err := p.Run(); err != nil {
log.Fatal("Failed starting the TUI", err)
}
}
package config
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/log"
"os"
)
type AuthMethod int
type SecurityProtocol string
const (
NoneAuthMethod AuthMethod = 0
SASLAuthMethod AuthMethod = 1
SASLPlaintextSecurityProtocol SecurityProtocol = "PLAIN_TEXT"
)
type SASLConfig struct {
Username string `yaml:"username"`
Password string `yaml:"password"`
SecurityProtocol SecurityProtocol `yaml:"securityProtocol"`
}
type SchemaRegistryConfig struct {
Url string `yaml:"url"`
Username string `yaml:"username"`
Password string `yaml:"password"`
}
type KafkaConnectConfig struct {
Name string `yaml:"name"`
Url string `yaml:"url"`
Username *string `yaml:"username"`
Password *string `yaml:"password"`
}
type Cluster struct {
Name string `yaml:"name"`
Color string `yaml:"color"`
Active bool `yaml:"active"`
BootstrapServers []string `yaml:"servers"`
SASLConfig *SASLConfig `yaml:"sasl"`
SchemaRegistry *SchemaRegistryConfig `yaml:"schema-registry"`
SSLEnabled bool `yaml:"ssl-enabled"`
KafkaConnectClusters []KafkaConnectConfig `yaml:"kafka-connect-clusters"`
}
func (c *Cluster) HasSchemaRegistry() bool {
return c.SchemaRegistry != nil
}
func (c *Cluster) HasKafkaConnect() bool {
return len(c.KafkaConnectClusters) > 0
}
type Config struct {
Clusters []Cluster `yaml:"clusters"`
ConfigIO IO `yaml:"-"`
}
func (c *Config) HasClusters() bool {
return len(c.Clusters) > 0
}
type SchemaRegistryDetails struct {
Url string
Username string
Password string
}
type KafkaConnectClusterDetails struct {
Name string
Url string
Username *string
Password *string
}
type RegistrationDetails struct {
Name string
Color string
Host string
AuthMethod AuthMethod
SecurityProtocol SecurityProtocol
SSLEnabled bool
NewName *string
Username string
Password string
SchemaRegistry *SchemaRegistryDetails
KafkaConnectClusters []KafkaConnectClusterDetails
}
type ClusterDeletedMsg struct {
Name string
}
type ClusterRegisteredMsg struct {
Cluster *Cluster
}
type ConnectClusterDeleted struct {
Name string
}
type ClusterRegisterer interface {
RegisterCluster(d RegistrationDetails) tea.Msg
}
type ConnectClusterDeleter interface {
DeleteKafkaConnectCluster(clusterName string, connectName string) tea.Msg
}
func (c *Config) DeleteKafkaConnectCluster(clusterName string, connectName string) tea.Msg {
for i, cluster := range c.Clusters {
if clusterName == cluster.Name {
for _, connectCluster := range cluster.KafkaConnectClusters {
if connectName == connectCluster.Name {
c.Clusters[i].KafkaConnectClusters = deleteKafkaConnectCluster(c.Clusters[i].KafkaConnectClusters, connectName)
c.flush()
return ConnectClusterDeleted{connectName}
}
}
}
}
return nil
}
func deleteKafkaConnectCluster(clusters []KafkaConnectConfig, name string) []KafkaConnectConfig {
out := make([]KafkaConnectConfig, 0, len(clusters)-1)
for _, c := range clusters {
if c.Name != name {
out = append(out, c)
}
}
return out
}
// RegisterCluster registers a new cluster or updates an existing one in the Config.
//
// If a cluster with the same name exists, it updates its details while retaining the "Active" status (the active param
// in that case is ignored) and optionally renaming it. Otherwise, it adds the cluster to the Config.
//
// It returns a ClusterRegisteredMsg with the registered cluster.
func (c *Config) RegisterCluster(details RegistrationDetails) tea.Msg {
cluster := ToCluster(details)
// When no clusters exist yet, the first one created becomes the active one by default.
if len(c.Clusters) == 0 {
cluster.Active = true
}
// did the newly registered cluster update an existing one
var isUpdated bool
for i := range c.Clusters {
if c.Clusters[i].Name == details.Name {
isActive := c.Clusters[i].Active
cluster.Active = isActive
c.Clusters[i] = cluster
if details.NewName != nil {
c.Clusters[i].Name = *details.NewName
}
isUpdated = true
break
}
}
// not updated, add new cluster
if !isUpdated {
c.Clusters = append(c.Clusters, cluster)
}
c.flush()
return ClusterRegisteredMsg{&cluster}
}
func ToCluster(details RegistrationDetails) Cluster {
cluster := Cluster{
Name: details.Name,
Color: details.Color,
BootstrapServers: []string{details.Host},
SSLEnabled: details.SSLEnabled,
}
if details.AuthMethod == SASLAuthMethod {
cluster.SASLConfig = &SASLConfig{
Username: details.Username,
Password: details.Password,
SecurityProtocol: details.SecurityProtocol,
}
}
if details.SchemaRegistry != nil {
cluster.SchemaRegistry = &SchemaRegistryConfig{
Url: details.SchemaRegistry.Url,
Username: details.SchemaRegistry.Username,
Password: details.SchemaRegistry.Password,
}
}
if details.KafkaConnectClusters != nil {
for _, connectCluster := range details.KafkaConnectClusters {
cluster.KafkaConnectClusters = append(cluster.KafkaConnectClusters, KafkaConnectConfig{
Name: connectCluster.Name,
Url: connectCluster.Url,
Username: connectCluster.Username,
Password: connectCluster.Password,
})
}
}
return cluster
}
func (c *Config) ActiveCluster() *Cluster {
for _, c := range c.Clusters {
if c.Active {
return &c
}
}
if len(c.Clusters) > 0 {
return &c.Clusters[0]
}
return nil
}
func (c *Config) flush() {
if err := c.ConfigIO.write(c); err != nil {
fmt.Println("Unable to write config file")
os.Exit(-1)
}
log.Debug("flushed config")
}
func (c *Config) SwitchCluster(name string) *Cluster {
var activeCluster *Cluster
for i := range c.Clusters {
// deactivate all clusters
c.Clusters[i].Active = false
if c.Clusters[i].Name == name {
c.Clusters[i].Active = true
activeCluster = &c.Clusters[i]
log.Debug("switched to cluster " + name)
}
}
c.flush()
return activeCluster
}
func (c *Config) DeleteCluster(name string) {
// Find the index of the cluster to delete
index := -1
for i, cluster := range c.Clusters {
if cluster.Name == name {
index = i
break
}
}
if index == -1 {
log.Warn("cluster not found: " + name)
return
}
// Check if the cluster to delete is active
isActive := c.Clusters[index].Active
// Remove the cluster
c.Clusters = append(c.Clusters[:index], c.Clusters[index+1:]...)
// Reactivate the first cluster if needed
if isActive && len(c.Clusters) > 0 {
c.Clusters[0].Active = true
}
c.flush()
log.Debug("deleted cluster: " + name)
}
func (c *Config) FindClusterByName(name string) *Cluster {
for _, cluster := range c.Clusters {
if cluster.Name == name {
return &cluster
}
}
return nil
}
type LoadedMsg struct {
Config *Config
}
func New(configIO IO) *Config {
config, err := configIO.read()
if err != nil {
fmt.Println("Error reading config file:", err)
os.Exit(-1)
}
config.ConfigIO = configIO
return config
}
package config
type InMemoryConfigIO struct {
config *Config
}
func (i *InMemoryConfigIO) write(config *Config) error {
i.config = config
return nil
}
func (i InMemoryConfigIO) read() (*Config, error) {
if i.config == nil {
i.config = &Config{}
}
return i.config, nil
}
func NewInMemoryConfigIO(config *Config) IO {
io := InMemoryConfigIO{}
io.config = config
io.config.ConfigIO = &io
return &io
}
package kadmin
import tea "github.com/charmbracelet/bubbletea"
type CGroupDeleter interface {
DeleteCGroup(name string) tea.Msg
}
type CGroupDeletionStartedMsg struct {
GroupName string
Deleted chan bool
Err chan error
}
func (c *CGroupDeletionStartedMsg) AwaitCompletion() tea.Msg {
select {
case <-c.Deleted:
return CGroupDeletedMsg{GroupName: c.GroupName}
case err := <-c.Err:
return CGroupDeletionErrMsg{Err: err}
}
}
type CGroupDeletionErrMsg struct {
Err error
}
type CGroupDeletedMsg struct {
GroupName string
}
func (ka *SaramaKafkaAdmin) DeleteCGroup(name string) tea.Msg {
errChan := make(chan error)
deletedChan := make(chan bool)
go ka.doDeleteCGroup(name, deletedChan, errChan)
return CGroupDeletionStartedMsg{
GroupName: name,
Deleted: deletedChan,
Err: errChan,
}
}
func (ka *SaramaKafkaAdmin) doDeleteCGroup(
name string,
deletedChan chan bool,
errChan chan error,
) {
err := ka.admin.DeleteConsumerGroup(name)
if err != nil {
errChan <- err
return
}
deletedChan <- true
}
package kadmin
import tea "github.com/charmbracelet/bubbletea"
type CGroupLister interface {
ListCGroups() tea.Msg
}
type ConsumerGroup struct {
Name string
Members []GroupMember
}
type ConsumerGroupListingStartedMsg struct {
Err chan error
ConsumerGroups chan []*ConsumerGroup
}
func (msg *ConsumerGroupListingStartedMsg) AwaitCompletion() tea.Msg {
select {
case groups := <-msg.ConsumerGroups:
return ConsumerGroupsListedMsg{groups}
case err := <-msg.Err:
return ConsumerGroupListingErrorMsg{err}
}
}
type ConsumerGroupsListedMsg struct {
ConsumerGroups []*ConsumerGroup
}
type ConsumerGroupListingErrorMsg struct {
Err error
}
func (ka *SaramaKafkaAdmin) ListCGroups() tea.Msg {
errChan := make(chan error)
groupsChan := make(chan []*ConsumerGroup)
go ka.doListConsumerGroups(groupsChan, errChan)
return ConsumerGroupListingStartedMsg{errChan, groupsChan}
}
func (ka *SaramaKafkaAdmin) doListConsumerGroups(groupsChan chan []*ConsumerGroup, errorChan chan error) {
MaybeIntroduceLatency()
if listGroupResponse, err := ka.admin.ListConsumerGroups(); err != nil {
errorChan <- err
} else {
var consumerGroups []*ConsumerGroup
var groupNames []string
var groupByName = make(map[string]*ConsumerGroup)
for name, _ := range listGroupResponse {
consumerGroup := ConsumerGroup{Name: name}
consumerGroups = append(consumerGroups, &consumerGroup)
groupByName[name] = &consumerGroup
groupNames = append(groupNames, name)
}
describeConsumerGroupResponse, err := ka.admin.DescribeConsumerGroups(groupNames)
if err != nil {
errorChan <- err
return
}
for _, groupDescription := range describeConsumerGroupResponse {
group := groupByName[groupDescription.GroupId]
var groupMembers []GroupMember
for _, m := range groupDescription.Members {
member := GroupMember{}
member.MemberId = m.MemberId
member.ClientId = m.ClientId
member.ClientHost = m.ClientHost
groupMembers = append(groupMembers, member)
}
group.Members = groupMembers
}
groupsChan <- consumerGroups
}
}
package kadmin
import (
"fmt"
"sort"
"github.com/IBM/sarama"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/log"
)
type ClusterConfigLister interface {
GetClusterConfig() tea.Msg
}
type BrokerConfigLister interface {
GetBrokerConfig(brokerID int32) tea.Msg
}
type Broker struct {
ID int32
Address string
}
type ClusterConfig struct {
Brokers []Broker
}
type ClusterConfigStartedMsg struct {
Err chan error
Configs chan ClusterConfig
}
func (m *ClusterConfigStartedMsg) AwaitCompletion() tea.Msg {
select {
case e := <-m.Err:
return ClusterConfigErrorMsg{e}
case c := <-m.Configs:
return ClusterConfigMsg{c}
}
}
type ClusterConfigMsg struct {
Config ClusterConfig
}
type ClusterConfigErrorMsg struct {
Err error
}
// GetClusterConfig retrieves the configuration of the Kafka cluster
func (ka *SaramaKafkaAdmin) GetClusterConfig() tea.Msg {
MaybeIntroduceLatency()
errChan := make(chan error)
configsChan := make(chan ClusterConfig)
go ka.doGetClusterConfig(errChan, configsChan)
log.Debug("Cluster configuration retrieval started")
return ClusterConfigStartedMsg{
Err: errChan,
Configs: configsChan,
}
}
func (ka *SaramaKafkaAdmin) doGetClusterConfig(errChan chan error, configsChan chan ClusterConfig) {
log.Debug("Fetching cluster configuration...")
saramaBrokers, _, err := ka.admin.DescribeCluster()
if err != nil {
log.Error("Failed to describe cluster", "error", err)
errChan <- err
return
}
sort.Slice(saramaBrokers, func(i, j int) bool {
return saramaBrokers[i].ID() < saramaBrokers[j].ID()
})
brokers := make([]Broker, 0)
for _, saramaBroker := range saramaBrokers {
brokers = append(brokers, Broker{
ID: saramaBroker.ID(),
Address: saramaBroker.Addr(),
})
}
log.Debug("Cluster configuration fetched successfully", "brokers", len(brokers))
configsChan <- ClusterConfig{Brokers: brokers}
close(configsChan)
}
type BrokerConfigListingStartedMsg struct {
Err chan error
Configs chan BrokerConfig
}
type BrokerConfig struct {
ID int32
Configs map[string]string
}
type BrokerConfigListedMsg struct {
Config BrokerConfig
}
type BrokerConfigErrorMsg struct {
Err error
}
func (m *BrokerConfigListingStartedMsg) AwaitCompletion() tea.Msg {
select {
case e := <-m.Err:
return BrokerConfigErrorMsg{e}
case c := <-m.Configs:
return BrokerConfigListedMsg{c}
}
}
// GetBrokerConfig retrieves the configuration for a specific broker in the Kafka cluster
func (ka *SaramaKafkaAdmin) GetBrokerConfig(brokerID int32) tea.Msg {
MaybeIntroduceLatency()
log.Debug("Fetching config for broker", "brokerID", brokerID)
errChan := make(chan error)
configsChan := make(chan BrokerConfig)
go ka.doGetBrokerConfig(brokerID, errChan, configsChan)
log.Debug("Broker configuration retrieval started", "brokerID", brokerID)
return BrokerConfigListingStartedMsg{
Err: errChan,
Configs: configsChan,
}
}
func (ka *SaramaKafkaAdmin) doGetBrokerConfig(brokerID int32, errChan chan error, configsChan chan BrokerConfig) {
log.Debug("Fetching broker config", "brokerID", brokerID)
resource := sarama.ConfigResource{
Type: sarama.BrokerResource,
Name: fmt.Sprintf("%d", brokerID),
}
entries, err := ka.admin.DescribeConfig(resource)
if err != nil {
log.Error("Failed to describe broker config", "brokerID", brokerID, "error", err)
errChan <- err
return
}
configMap := make(map[string]string)
for _, entry := range entries {
configMap[entry.Name] = entry.Value
}
configsChan <- BrokerConfig{ID: brokerID, Configs: configMap}
close(configsChan)
}
package kadmin
import (
"github.com/IBM/sarama"
tea "github.com/charmbracelet/bubbletea"
)
type TopicConfigLister interface {
ListConfigs(topic string) tea.Msg
}
type TopicConfigListingStartedMsg struct {
Err chan error
Configs chan map[string]string
}
type TopicConfigsListedMsg struct {
Configs map[string]string
}
type TopicConfigListingErrorMsg struct {
Err error
}
func (m *TopicConfigListingStartedMsg) AwaitCompletion() tea.Msg {
select {
case e := <-m.Err:
return TopicConfigListingErrorMsg{e}
case c := <-m.Configs:
return TopicConfigsListedMsg{c}
}
}
func (ka *SaramaKafkaAdmin) ListConfigs(topic string) tea.Msg {
errChan := make(chan error)
configsChan := make(chan map[string]string)
go ka.doListConfigs(topic, configsChan, errChan)
return TopicConfigListingStartedMsg{
errChan,
configsChan,
}
}
func (ka *SaramaKafkaAdmin) doListConfigs(topic string, configsChan chan map[string]string, errorChan chan error) {
MaybeIntroduceLatency()
configsResp, err := ka.admin.DescribeConfig(sarama.ConfigResource{
Type: TopicResourceType,
Name: topic,
})
if err != nil {
errorChan <- err
return
}
configs := make(map[string]string)
for _, e := range configsResp {
configs[e.Name] = e.Value
}
configsChan <- configs
}
package kadmin
import tea "github.com/charmbracelet/bubbletea"
type ConfigUpdater interface {
UpdateConfig(t TopicConfigToUpdate) tea.Msg
}
type TopicConfigUpdatedMsg struct{}
type TopicConfigToUpdate struct {
Topic string
Key string
Value string
}
type UpdateTopicConfigErrorMsg struct {
Reason string
}
func (ka *SaramaKafkaAdmin) UpdateConfig(t TopicConfigToUpdate) tea.Msg {
err := ka.admin.AlterConfig(
TopicResourceType,
t.Topic,
map[string]*string{t.Key: &t.Value},
false,
)
if err != nil {
return KAdminErrorMsg{err}
}
return TopicConfigUpdatedMsg{}
}
package kadmin
import (
"ktea/config"
tea "github.com/charmbracelet/bubbletea"
)
const (
PlainText SASLProtocol = 0
)
const (
TopicResourceType = 2
)
type Kadmin interface {
TopicCreator
TopicDeleter
TopicLister
Publisher
RecordReader
OffsetLister
CGroupLister
CGroupDeleter
ConfigUpdater
TopicConfigLister
SraSetter
ClusterConfigLister
BrokerConfigLister
}
type ConnectionDetails struct {
BootstrapServers []string
SASLConfig *SASLConfig
SSLEnabled bool
}
type SASLProtocol int
type SASLConfig struct {
Username string
Password string
Protocol SASLProtocol
}
type GroupMember struct {
MemberId string
ClientId string
ClientHost string
}
type KAdminErrorMsg struct {
Error error
}
type ConnErrMsg struct {
Err error
}
type Instantiator func(cd ConnectionDetails) (Kadmin, error)
type ConnChecker func(cluster *config.Cluster) tea.Msg
func SaramaInstantiator() Instantiator {
return func(cd ConnectionDetails) (Kadmin, error) {
return NewSaramaKadmin(cd)
}
}
package kadmin
import tea "github.com/charmbracelet/bubbletea"
type OffsetLister interface {
ListOffsets(group string) tea.Msg
}
type TopicPartitionOffset struct {
Topic string
Partition int32
Offset int64
}
type OffsetListingStartedMsg struct {
Err chan error
Offsets chan []TopicPartitionOffset
}
func (msg *OffsetListingStartedMsg) AwaitCompletion() tea.Msg {
select {
case offsets := <-msg.Offsets:
return OffsetListedMsg{offsets}
case err := <-msg.Err:
return OffsetListingErrorMsg{err}
}
}
type OffsetListedMsg struct {
Offsets []TopicPartitionOffset
}
type OffsetListingErrorMsg struct {
Err error
}
func (ka *SaramaKafkaAdmin) ListOffsets(group string) tea.Msg {
errChan := make(chan error)
offsets := make(chan []TopicPartitionOffset)
go ka.doListOffsets(group, offsets, errChan)
return OffsetListingStartedMsg{
errChan,
offsets,
}
}
func (ka *SaramaKafkaAdmin) doListOffsets(group string, offsetsChan chan []TopicPartitionOffset, errChan chan error) {
MaybeIntroduceLatency()
listResult, err := ka.admin.ListConsumerGroupOffsets(group, nil)
if err != nil {
errChan <- err
}
var topicPartitionOffsets []TopicPartitionOffset
for t, m := range listResult.Blocks {
for p, block := range m {
topicPartitionOffsets = append(topicPartitionOffsets, TopicPartitionOffset{
Topic: t,
Partition: p,
Offset: block.Offset,
})
}
}
offsetsChan <- topicPartitionOffsets
}
//go:build !dev
package kadmin
func MaybeIntroduceLatency() {
}
package kadmin
import (
"bytes"
"context"
"encoding/binary"
"github.com/charmbracelet/log"
"ktea/serdes"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"unicode/utf8"
"github.com/IBM/sarama"
tea "github.com/charmbracelet/bubbletea"
)
type FilterType string
func (filterDetails *Filter) Filter(value string) bool {
switch filterDetails.KeyFilter {
case ContainsFilterType:
return strings.Contains(value, filterDetails.KeySearchTerm)
case StartsWithFilterType:
return strings.HasPrefix(value, filterDetails.KeySearchTerm)
default:
return true
}
}
const (
ContainsFilterType FilterType = "contains"
StartsWithFilterType FilterType = "starts with"
NoFilterType FilterType = "none"
)
type StartPoint int
const (
Beginning StartPoint = 0
MostRecent StartPoint = 1
Live StartPoint = 2
)
type RecordReader interface {
ReadRecords(ctx context.Context, rd ReadDetails) tea.Msg
}
type ReadingStartedMsg struct {
ConsumerRecord chan ConsumerRecord
EmptyTopic chan bool
Err chan error
CancelFunc context.CancelFunc
}
type Filter struct {
KeyFilter FilterType
KeySearchTerm string
ValueFilter FilterType
ValueSearchTerm string
}
type ReadDetails struct {
TopicName string
PartitionToRead []int
StartPoint StartPoint
Limit int
Filter *Filter
}
type HeaderValue struct {
data []byte
}
func NewHeaderValue(data string) HeaderValue {
return HeaderValue{[]byte(data)}
}
func (v HeaderValue) String() string {
if utf8.Valid(v.data) {
return string(v.data)
}
if len(v.data) >= 4 {
var int32Val int32
err := binary.Read(bytes.NewReader(v.data), binary.BigEndian, &int32Val)
if err == nil {
return string(int32Val)
}
}
if len(v.data) >= 8 {
var int64Val int64
err := binary.Read(bytes.NewReader(v.data), binary.BigEndian, &int64Val)
if err == nil {
return strconv.FormatInt(int64Val, 10)
}
}
if len(v.data) >= 4 {
var float32Val float32
err := binary.Read(bytes.NewReader(v.data), binary.BigEndian, &float32Val)
if err == nil {
return strconv.FormatFloat(float64(float32Val), 'f', -1, 32)
}
}
if len(v.data) >= 8 {
var float64Val float64
err := binary.Read(bytes.NewReader(v.data), binary.BigEndian, &float64Val)
if err == nil {
return strconv.FormatFloat(float64Val, 'f', -1, 64)
}
}
return string(v.data)
}
type Header struct {
Key string
Value HeaderValue
}
type ConsumerRecord struct {
Key string
Payload serdes.DesData
Err error
Partition int64
Offset int64
Headers []Header
Timestamp time.Time
}
type offsets struct {
oldest int64
// most recent available, unused, offset
firstAvailable int64
}
func (o *offsets) newest() int64 {
return o.firstAvailable - 1
}
func (ka *SaramaKafkaAdmin) ReadRecords(ctx context.Context, rd ReadDetails) tea.Msg {
ctx, cancelFunc := context.WithCancel(ctx)
startedMsg := ReadingStartedMsg{
ConsumerRecord: make(chan ConsumerRecord, len(rd.PartitionToRead)),
Err: make(chan error),
EmptyTopic: make(chan bool),
CancelFunc: cancelFunc,
}
go ka.doReadRecords(ctx, rd, startedMsg, cancelFunc)
return startedMsg
}
func (ka *SaramaKafkaAdmin) doReadRecords(
ctx context.Context,
rd ReadDetails,
startedMsg ReadingStartedMsg,
cancelFunc context.CancelFunc,
) {
client, err := sarama.NewConsumerFromClient(ka.client)
if err != nil {
close(startedMsg.ConsumerRecord)
close(startedMsg.Err)
}
var (
msgCount atomic.Int64
closeOnce sync.Once
wg sync.WaitGroup
offsets map[int]offsets
)
offsets, err = ka.fetchOffsets(rd.PartitionToRead, rd.TopicName)
if err != nil {
startedMsg.Err <- err
close(startedMsg.ConsumerRecord)
close(startedMsg.Err)
cancelFunc()
}
wg.Add(len(rd.PartitionToRead))
emptyTopic := true
for _, partition := range rd.PartitionToRead {
// if there is no data in the partition, we don't need to read it unless live consumption is requested
if offsets[partition].firstAvailable != offsets[partition].oldest || rd.StartPoint == Live {
emptyTopic = false
go func(partition int) {
defer wg.Done()
readingOffsets := ka.determineReadingOffsets(rd, offsets[partition])
consumer, err := client.ConsumePartition(
rd.TopicName,
int32(partition),
readingOffsets.start,
)
if err != nil {
startedMsg.Err <- err
cancelFunc()
return
}
defer consumer.Close()
msgChan := consumer.Messages()
for {
select {
case err := <-consumer.Errors():
startedMsg.Err <- err
return
case <-ctx.Done():
return
case msg := <-msgChan:
var headers []Header
for _, h := range msg.Headers {
headers = append(headers, Header{
string(h.Key),
HeaderValue{h.Value},
})
}
var desData serdes.DesData
key := string(msg.Key)
desData, err = ka.deserialize(msg)
if rd.Filter != nil && err == nil {
if !ka.matchesFilter(key, desData.Value, rd.Filter) {
continue
}
}
consumerRecord := ConsumerRecord{
Key: key,
Payload: desData,
Err: err,
Partition: int64(msg.Partition),
Offset: msg.Offset,
Headers: headers,
Timestamp: msg.Timestamp,
}
var shouldClose bool
if msgCount.Add(1) >= int64(rd.Limit) {
shouldClose = true
}
select {
case startedMsg.ConsumerRecord <- consumerRecord:
case <-ctx.Done():
return
}
if shouldClose {
cancelFunc() // Cancel the context to stop other goroutines
return
}
if msg.Offset == readingOffsets.end && rd.StartPoint != Live {
return
}
}
}
}(partition)
}
}
if emptyTopic {
cancelFunc()
startedMsg.EmptyTopic <- true
}
go func() {
wg.Wait()
closeOnce.Do(func() {
close(startedMsg.ConsumerRecord)
close(startedMsg.Err)
})
}()
}
func (ka *SaramaKafkaAdmin) matchesFilter(key, value string, filterDetails *Filter) bool {
if filterDetails == nil {
return true
}
if filterDetails.KeyFilter != NoFilterType {
return filterDetails.Filter(key)
}
if filterDetails.ValueSearchTerm != "" && !strings.Contains(value, filterDetails.ValueSearchTerm) {
return false
}
return true
}
func (ka *SaramaKafkaAdmin) deserialize(
msg *sarama.ConsumerMessage,
) (serdes.DesData, error) {
deserializer := serdes.NewAvroDeserializer(ka.sra)
return deserializer.Deserialize(msg.Value)
}
type readingOffsets struct {
start int64
end int64
}
func (ka *SaramaKafkaAdmin) determineReadingOffsets(
rd ReadDetails,
offsets offsets,
) readingOffsets {
if rd.StartPoint == Live {
return readingOffsets{
start: offsets.firstAvailable,
end: -1,
}
}
var startOffset int64
var endOffset int64
numberOfRecordsPerPart := int64(float64(int64(rd.Limit)) / float64(len(rd.PartitionToRead)))
if rd.StartPoint == Beginning {
startOffset, endOffset = ka.determineOffsetsFromBeginning(
startOffset,
offsets,
numberOfRecordsPerPart,
endOffset,
)
} else {
startOffset, endOffset = ka.determineMostRecentOffsets(
startOffset,
offsets,
numberOfRecordsPerPart,
endOffset,
)
}
return readingOffsets{
start: startOffset,
end: endOffset,
}
}
func (ka *SaramaKafkaAdmin) determineMostRecentOffsets(
startOffset int64,
offsets offsets,
numberOfRecordsPerPart int64,
endOffset int64,
) (int64, int64) {
startOffset = offsets.newest() - numberOfRecordsPerPart
endOffset = offsets.newest()
if startOffset < 0 || startOffset < offsets.oldest {
startOffset = offsets.oldest
}
return startOffset, endOffset
}
func (ka *SaramaKafkaAdmin) determineOffsetsFromBeginning(
startOffset int64,
offsets offsets,
numberOfRecordsPerPart int64,
endOffset int64,
) (int64, int64) {
startOffset = offsets.oldest
if offsets.oldest+numberOfRecordsPerPart < offsets.newest() {
endOffset = startOffset + numberOfRecordsPerPart - 1
} else {
endOffset = offsets.newest()
}
return startOffset, endOffset
}
func (ka *SaramaKafkaAdmin) fetchOffsets(
partitions []int,
topicName string,
) (map[int]offsets, error) {
offsetsByPartition := make(map[int]offsets)
var wg sync.WaitGroup
var mu sync.Mutex
errorsChan := make(chan error, len(partitions))
for _, partition := range partitions {
log.Debug("fetching offsets", "topic", topicName, "partition", partition)
wg.Add(1)
go func(partition int) {
defer wg.Done()
firstAvailableOffset, err := ka.client.GetOffset(
topicName,
int32(partition),
sarama.OffsetNewest,
)
if err != nil {
errorsChan <- err
return
}
oldestOffset, err := ka.client.GetOffset(
topicName,
int32(partition),
sarama.OffsetOldest,
)
if err != nil {
errorsChan <- err
return
}
mu.Lock()
offsetsByPartition[partition] = offsets{
oldestOffset,
firstAvailableOffset,
}
mu.Unlock()
}(partition)
}
wg.Wait()
select {
case err := <-errorsChan:
return nil, err
default:
return offsetsByPartition, nil
}
}
package kadmin
import (
"fmt"
"github.com/IBM/sarama"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/log"
"ktea/config"
"ktea/sradmin"
"time"
)
type SaramaKafkaAdmin struct {
client sarama.Client
admin sarama.ClusterAdmin
addrs []string
config *sarama.Config
producer sarama.SyncProducer
sra sradmin.Client
}
type ConnCheckStartedMsg struct {
Cluster *config.Cluster
Connected chan bool
Err chan error
}
func (c *ConnCheckStartedMsg) AwaitCompletion() tea.Msg {
select {
case <-c.Connected:
return ConnCheckSucceededMsg{}
case err := <-c.Err:
return ConnCheckErrMsg{Err: err}
}
}
type ConnCheckSucceededMsg struct{}
type ConnCheckErrMsg struct {
Err error
}
func ToConnectionDetails(cluster *config.Cluster) ConnectionDetails {
var saslConfig *SASLConfig
if cluster.SASLConfig != nil {
var protocol SASLProtocol
switch cluster.SASLConfig.SecurityProtocol {
// SSL, to make wrongly configured PLAINTEXT protocols (as SSL) compatible. Should be removed in the future.
case config.SASLPlaintextSecurityProtocol, "SSL":
protocol = PlainText
default:
panic(fmt.Sprintf("Unknown SASL protocol: %s", cluster.SASLConfig.SecurityProtocol))
}
saslConfig = &SASLConfig{
Username: cluster.SASLConfig.Username,
Password: cluster.SASLConfig.Password,
Protocol: protocol,
}
}
connDetails := ConnectionDetails{
BootstrapServers: cluster.BootstrapServers,
SASLConfig: saslConfig,
SSLEnabled: cluster.SSLEnabled,
}
return connDetails
}
func NewSaramaKadmin(cd ConnectionDetails) (Kadmin, error) {
cfg := sarama.NewConfig()
cfg.Producer.Return.Successes = true
cfg.Producer.RequiredAcks = sarama.WaitForAll
cfg.Producer.Partitioner = sarama.NewRoundRobinPartitioner
cfg.Consumer.Offsets.Initial = sarama.OffsetOldest
cfg.Net.TLS.Enable = cd.SSLEnabled
if cd.SASLConfig != nil {
cfg.Net.SASL.Enable = true
cfg.Net.SASL.Mechanism = sarama.SASLTypePlaintext
cfg.Net.SASL.User = cd.SASLConfig.Username
cfg.Net.SASL.Password = cd.SASLConfig.Password
}
client, err := sarama.NewClient(cd.BootstrapServers, cfg)
if err != nil {
return nil, err
}
admin, err := sarama.NewClusterAdmin(cd.BootstrapServers, cfg)
if err != nil {
return nil, err
}
producer, err := sarama.NewSyncProducerFromClient(client)
if err != nil {
return nil, err
}
return &SaramaKafkaAdmin{
client: client,
admin: admin,
addrs: cd.BootstrapServers,
producer: producer,
config: cfg,
}, nil
}
func CheckKafkaConnectivity(cluster *config.Cluster) tea.Msg {
connectedChan := make(chan bool)
errChan := make(chan error)
cd := ToConnectionDetails(cluster)
cfg := sarama.NewConfig()
cfg.Net.TLS.Enable = cd.SSLEnabled
if cd.SASLConfig != nil {
cfg.Net.SASL.Enable = true
cfg.Net.SASL.Mechanism = sarama.SASLTypePlaintext
cfg.Net.SASL.User = cd.SASLConfig.Username
cfg.Net.SASL.Password = cd.SASLConfig.Password
cfg.Net.DialTimeout = 5 * time.Second
cfg.Net.ReadTimeout = 5 * time.Second
cfg.Net.WriteTimeout = 5 * time.Second
}
go doCheckConnectivity(cd, cfg, errChan, connectedChan)
return ConnCheckStartedMsg{
Cluster: cluster,
Connected: connectedChan,
Err: errChan,
}
}
func doCheckConnectivity(cd ConnectionDetails, config *sarama.Config, errChan chan error, connectedChan chan bool) {
MaybeIntroduceLatency()
c, err := sarama.NewClient(cd.BootstrapServers, config)
if err != nil {
errChan <- err
return
}
defer func(c sarama.Client) {
err := c.Close()
if err != nil {
log.Error("Unable to close connectivity check connection", err)
}
}(c)
connectedChan <- true
}
package kadmin
import (
"ktea/sradmin"
)
type SraSetter interface {
SetSra(sra sradmin.Client)
}
func (ka *SaramaKafkaAdmin) SetSra(sra sradmin.Client) {
ka.sra = sra
}
package kadmin
import (
"github.com/IBM/sarama"
tea "github.com/charmbracelet/bubbletea"
)
type TopicCreator interface {
CreateTopic(tcd TopicCreationDetails) tea.Msg
}
type TopicCreationDetails struct {
Name string
NumPartitions int
Properties map[string]string
ReplicationFactor int16
}
type TopicCreatedMsg struct {
}
type TopicCreationErrMsg struct {
Err error
}
type TopicCreationStartedMsg struct {
Created chan bool
Err chan error
}
func (msg *TopicCreationStartedMsg) AwaitCompletion() tea.Msg {
select {
case <-msg.Created:
return TopicCreatedMsg{}
case err := <-msg.Err:
return TopicCreationErrMsg{Err: err}
}
}
func (ka *SaramaKafkaAdmin) CreateTopic(tcd TopicCreationDetails) tea.Msg {
created := make(chan bool)
err := make(chan error)
go ka.doCreateTopic(tcd, created, err)
return TopicCreationStartedMsg{
Created: created,
Err: err,
}
}
func (ka *SaramaKafkaAdmin) doCreateTopic(tcd TopicCreationDetails, created chan bool, errChan chan error) {
MaybeIntroduceLatency()
properties := make(map[string]*string)
for k, v := range tcd.Properties {
properties[k] = &v
}
err := ka.admin.CreateTopic(tcd.Name, &sarama.TopicDetail{
NumPartitions: int32(tcd.NumPartitions),
ReplicationFactor: tcd.ReplicationFactor,
ReplicaAssignment: nil,
ConfigEntries: properties,
}, false)
if err != nil {
errChan <- err
}
created <- true
}
package kadmin
import tea "github.com/charmbracelet/bubbletea"
type TopicDeleter interface {
DeleteTopic(topic string) tea.Msg
}
type TopicDeletedMsg struct {
TopicName string
}
type TopicDeletionStartedMsg struct {
Deleted chan bool
Err chan error
Topic string
}
type TopicDeletionErrorMsg struct {
Err error
}
func (m *TopicDeletionStartedMsg) AwaitCompletion() tea.Msg {
select {
case <-m.Deleted:
return TopicDeletedMsg{TopicName: m.Topic}
case err := <-m.Err:
return TopicDeletionErrorMsg{Err: err}
}
}
func (ka *SaramaKafkaAdmin) DeleteTopic(topic string) tea.Msg {
errChan := make(chan error)
deletedChan := make(chan bool)
go ka.doDeleteTopic(topic, deletedChan, errChan)
return TopicDeletionStartedMsg{
Topic: topic,
Deleted: deletedChan,
Err: errChan,
}
}
func (ka *SaramaKafkaAdmin) doDeleteTopic(
topic string,
deletedChan chan bool,
errChan chan error,
) {
MaybeIntroduceLatency()
err := ka.admin.DeleteTopic(topic)
if err != nil {
errChan <- err
}
deletedChan <- true
}
package kadmin
import (
tea "github.com/charmbracelet/bubbletea"
)
const UnknownRecordCount = -1
type TopicLister interface {
ListTopics() tea.Msg
}
type TopicsListedMsg struct {
Topics []ListedTopic
}
type TopicRecordCount struct {
Topic string
RecordCount int64
CountedTopic chan TopicRecordCount
}
type TopicListingStartedMsg struct {
Err chan error
Topics chan []ListedTopic
}
type TopicListedErrorMsg struct {
Err error
}
func (m *TopicListingStartedMsg) AwaitTopicListCompletion() tea.Msg {
select {
case topics := <-m.Topics:
return TopicsListedMsg{Topics: topics}
case err := <-m.Err:
return TopicListedErrorMsg{Err: err}
}
}
type ListedTopic struct {
Name string
PartitionCount int
Replicas int
}
func (t *ListedTopic) Partitions() []int {
partToConsume := make([]int, t.PartitionCount)
for i := range t.PartitionCount {
partToConsume[i] = i
}
return partToConsume
}
func (ka *SaramaKafkaAdmin) ListTopics() tea.Msg {
errChan := make(chan error)
topicsChan := make(chan []ListedTopic)
go ka.doListTopics(errChan, topicsChan)
return TopicListingStartedMsg{
errChan,
topicsChan,
}
}
func (ka *SaramaKafkaAdmin) doListTopics(
errChan chan error,
topicsChan chan []ListedTopic,
) {
MaybeIntroduceLatency()
listResult, err := ka.admin.ListTopics()
if err != nil {
errChan <- err
return
}
var topics []ListedTopic
for name, t := range listResult {
topics = append(topics, ListedTopic{
name,
int(t.NumPartitions),
int(t.ReplicationFactor),
})
}
topicsChan <- topics
close(topicsChan)
}
package kadmin
import (
"github.com/IBM/sarama"
"github.com/burdiyan/kafkautil"
tea "github.com/charmbracelet/bubbletea"
)
type Publisher interface {
PublishRecord(p *ProducerRecord) PublicationStartedMsg
}
type ProducerRecord struct {
Key string
Value []byte
Topic string
Partition *int
Headers map[string]string
}
type PublicationStartedMsg struct {
Err chan error
Published chan bool
}
type PublicationFailed struct {
Err error
}
type PublicationSucceeded struct {
}
func (p *PublicationStartedMsg) AwaitCompletion() tea.Msg {
select {
case err := <-p.Err:
return PublicationFailed{err}
case <-p.Published:
return PublicationSucceeded{}
}
}
func (ka *SaramaKafkaAdmin) PublishRecord(p *ProducerRecord) PublicationStartedMsg {
errChan := make(chan error)
published := make(chan bool)
go ka.doPublishRecord(p, errChan, published)
return PublicationStartedMsg{
Err: errChan,
Published: published,
}
}
func (ka *SaramaKafkaAdmin) doPublishRecord(
p *ProducerRecord,
errChan chan error,
published chan bool,
) {
MaybeIntroduceLatency()
var partition int32
if p.Partition == nil {
ka.config.Producer.Partitioner = kafkautil.NewJVMCompatiblePartitioner
} else {
partition = int32(*p.Partition)
ka.config.Producer.Partitioner = sarama.NewManualPartitioner
}
var headers []sarama.RecordHeader
for key, value := range p.Headers {
headers = append(headers, sarama.RecordHeader{
Key: []byte(key),
Value: []byte(value),
})
}
_, _, err := ka.producer.SendMessage(&sarama.ProducerMessage{
Topic: p.Topic,
Key: sarama.StringEncoder(p.Key),
Value: sarama.ByteEncoder(p.Value),
Partition: partition,
Headers: headers,
})
if err != nil {
errChan <- err
return
}
published <- true
}
package kcadmin
import (
tea "github.com/charmbracelet/bubbletea"
"ktea/config"
"net/http"
)
type ConnCheckStartedMsg struct {
ConnOk chan bool
Err chan error
}
type ConnCheckSucceededMsg struct{}
type ConnCheckErrMsg struct {
Err error
}
func (c *ConnCheckStartedMsg) AwaitCompletion() tea.Msg {
select {
case <-c.ConnOk:
return ConnCheckSucceededMsg{}
case err := <-c.Err:
return ConnCheckErrMsg{err}
}
}
func (k *DefaultKcAdmin) CheckConnection() tea.Msg {
req, err := k.NewRequest(http.MethodGet, "/", nil)
if err != nil {
return ConnectorListingErrMsg{err}
}
connOkChan := make(chan bool)
errChan := make(chan error)
go k.doCheckConnection(connOkChan, errChan, req)
return ConnCheckStartedMsg{connOkChan, errChan}
}
func (k *DefaultKcAdmin) doCheckConnection(connOkChan chan bool, conErrChan chan error, req *http.Request) {
versionChan := make(chan KafkaConnectVersion)
versionErrChan := make(chan error)
go execReq(
req,
k.client,
func(v KafkaConnectVersion) { versionChan <- v },
func(e error) { versionErrChan <- e },
)
select {
case <-versionChan:
connOkChan <- true
case err := <-versionErrChan:
conErrChan <- err
}
}
// CheckKafkaConnectClustersConn implements ConnChecker.
// CheckKafkaConnectClustersConn checks if the Kafka Connect is reachable and returns a tea.Msg to report status.
func CheckKafkaConnectClustersConn(c *config.KafkaConnectConfig) tea.Msg {
client := New(http.DefaultClient, c)
return client.CheckConnection()
}
package kcadmin
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/log"
"net/http"
)
func (k *DefaultKcAdmin) DeleteConnector(name string) tea.Msg {
req, err := k.NewRequest(http.MethodDelete, "/connectors/"+name, nil)
if err != nil {
log.Error("Error Deleting Kafka Connector", err)
return ConnectorDeletionErrMsg{err}
}
var (
dc = make(chan bool)
ec = make(chan error)
)
go execReq(
req,
k.client,
func(_ any) { dc <- true },
func(e error) { ec <- e },
)
return ConnectorDeletionStartedMsg{name, dc, ec}
}
package kcadmin
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/log"
"net/http"
)
func (k *DefaultKcAdmin) ListActiveConnectors() tea.Msg {
req, err := k.NewRequest(http.MethodGet, "/connectors?expand=status", nil)
if err != nil {
log.Error("Error Listing Kafka Connectors", err)
return ConnectorListingErrMsg{err}
}
var (
cChan = make(chan Connectors)
eChan = make(chan error)
)
go execReq(
req,
k.client,
func(r Connectors) { cChan <- r },
func(e error) { eChan <- e },
)
return ConnectorListingStartedMsg{cChan, eChan}
}
package kcadmin
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/log"
"net/http"
)
func (k *DefaultKcAdmin) Pause(name string) tea.Msg {
req, err := k.NewRequest(http.MethodPut, fmt.Sprintf("/connectors/%s/pause", name), nil)
if err != nil {
log.Error("Error Pausing "+name+" Kafka Connector", err)
return PausingErrMsg{err}
}
var (
pChan = make(chan bool)
eChan = make(chan error)
)
go execReq(
req,
k.client,
func(_ any) { pChan <- true },
func(e error) { eChan <- e },
)
return PausingStartedMsg{pChan, eChan, name}
}
package kcadmin
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/log"
"net/http"
)
type ResumingErrMsg struct {
Err error
}
type ResumingStartedMsg struct {
Resumed chan bool
Err chan error
Name string
}
type ResumeRequestedMsg struct{}
func (c *ResumingStartedMsg) AwaitCompletion() tea.Msg {
select {
case <-c.Resumed:
return ResumeRequestedMsg{}
case err := <-c.Err:
return ResumingErrMsg{err}
}
}
func (k *DefaultKcAdmin) Resume(name string) tea.Msg {
req, err := k.NewRequest(http.MethodPut, fmt.Sprintf("/connectors/%s/resume", name), nil)
if err != nil {
log.Error("Error Resuming "+name+" Kafka Connector", err)
return ResumingErrMsg{err}
}
var (
pChan = make(chan bool)
eChan = make(chan error)
)
go execReq(
req,
k.client,
func(_ any) { pChan <- true },
func(e error) { eChan <- e },
)
return ResumingStartedMsg{pChan, eChan, name}
}
package kcadmin
import (
tea "github.com/charmbracelet/bubbletea"
"net/http"
)
func (k *DefaultKcAdmin) ListVersion() tea.Msg {
req, err := k.NewRequest(http.MethodGet, "/", nil)
if err != nil {
return VersionListingErrMsg{err}
}
versionChan := make(chan KafkaConnectVersion)
errChan := make(chan error)
go execReq(
req,
k.client,
func(r KafkaConnectVersion) { versionChan <- r },
func(e error) { errChan <- e },
)
return VersionListingStartedMsg{versionChan, errChan}
}
package kcadmin
import (
"encoding/json"
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/log"
"io"
"ktea/config"
"ktea/kadmin"
"net/http"
)
type Admin interface {
ConnectorLister
ConnectorDeleter
VersionLister
Pauser
Resumer
}
// ConnectorLister defines the behavior of listing active Kafka connectors.
// returns a tea.Msg that can be either a ConnectorListingStartedMsg or a ConnectorListingErrMsg
type ConnectorLister interface {
ListActiveConnectors() tea.Msg
}
type ConnectorDeleter interface {
DeleteConnector(name string) tea.Msg
}
// VersionLister defines the behavior of listing the kafka connect version
// return a tea.Msg that can either be a VersionListingStartedMsg or a VersionListingErrMsg
type VersionLister interface {
ListVersion() tea.Msg
}
// Pauser Pauses the connector and its tasks by its name
// return a tea.Msg that can either be a PausingStartedMsg or a PausingErrMsg
type Pauser interface {
Pause(name string) tea.Msg
}
type Resumer interface {
Resume(name string) tea.Msg
}
// ConnChecker is a function that checks a Kafka Connect Cluster connection and returns a tea.Msg.
type ConnChecker func(c *config.KafkaConnectConfig) tea.Msg
type ConnectorStatus struct {
Name string `json:"name"`
Connector ConnectorState `json:"connector"`
Tasks []TaskState `json:"tasks"`
Type string `json:"type"`
}
type ConnectorState struct {
State string `json:"state"`
WorkerID string `json:"worker_id"`
}
type TaskState struct {
ID int `json:"id"`
State string `json:"state"`
WorkerID string `json:"worker_id"`
}
type Connectors map[string]struct {
Status ConnectorStatus `json:"status"`
}
type Client interface {
Do(req *http.Request) (*http.Response, error)
}
type DefaultKcAdmin struct {
client Client
baseUrl string
username *string
password *string
}
type ConnectorListingStartedMsg struct {
Connectors chan Connectors
Err chan error
}
type ConnectorsListedMsg struct {
Connectors
}
type KafkaConnectVersion struct {
Version string `json:"version"`
ClusterId string `json:"kafka_cluster_id"`
}
type VersionListingStartedMsg struct {
Version chan KafkaConnectVersion
Err chan error
}
type VersionListingErrMsg struct {
Err error
}
type PausingStartedMsg struct {
Paused chan bool
Err chan error
Name string
}
type PauseRequestedMsg struct {
Name string
}
func (c *PausingStartedMsg) AwaitCompletion() tea.Msg {
select {
case <-c.Paused:
return PauseRequestedMsg{c.Name}
case err := <-c.Err:
return PausingErrMsg{err}
}
}
type PausingErrMsg struct {
Err error
}
func (c *ConnectorListingStartedMsg) AwaitCompletion() tea.Msg {
select {
case con := <-c.Connectors:
return ConnectorsListedMsg{con}
case err := <-c.Err:
return ConnectorListingErrMsg{err}
}
}
type ConnectorListingErrMsg struct {
Err error
}
type ConnectorDeletionStartedMsg struct {
Name string
Deleted chan bool
Err chan error
}
func (m *ConnectorDeletionStartedMsg) AwaitCompletion() tea.Msg {
select {
case <-m.Deleted:
return ConnectorDeletedMsg{m.Name}
case err := <-m.Err:
return ConnectorDeletionErrMsg{err}
}
}
type ConnectorDeletedMsg struct {
Name string
}
type ConnectorDeletionErrMsg struct {
Err error
}
func (k *DefaultKcAdmin) url(path string) string {
return k.baseUrl + path
}
func (k *DefaultKcAdmin) NewRequest(
method string,
path string,
body io.Reader,
) (*http.Request, error) {
req, err := http.NewRequest(method, k.url(path), body)
if err != nil {
return nil, err
}
if k.password != nil && k.username != nil {
req.SetBasicAuth(*k.username, *k.password)
}
return req, nil
}
type successFunc[T any] func(body T)
type errorFunc func(err error)
func execReq[T any](
req *http.Request,
client Client,
sf successFunc[T],
ef errorFunc,
) {
kadmin.MaybeIntroduceLatency()
log.Debug("Executing request", "request", req)
resp, err := client.Do(req)
if err != nil {
log.Error("Error during request", "error", err)
ef(err)
return
}
if resp.StatusCode >= 200 && resp.StatusCode <= 299 {
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
ef(err)
}
}(resp.Body)
if resp.ContentLength == 0 || resp.StatusCode == http.StatusNoContent {
log.Info("Executed Request Successfully without content")
var res T
sf(res)
return
}
b, err := io.ReadAll(resp.Body)
if err != nil {
log.Error("Error Reading Response Body", "error", err)
ef(err)
return
}
var res T
if err := json.Unmarshal(b, &res); err != nil {
log.Error("Error Unmarshalling", "error", err)
ef(err)
return
}
log.Info("Request executed successfully", "statusCode", resp.StatusCode)
sf(res)
} else {
log.Error("Error", "statusCode", resp.StatusCode)
ef(fmt.Errorf("Error unexpected response code (%d)", resp.StatusCode))
}
}
func New(c Client, config *config.KafkaConnectConfig) *DefaultKcAdmin {
return &DefaultKcAdmin{client: c, baseUrl: config.Url, username: config.Username, password: config.Password}
}
package kontext
import (
"ktea/config"
)
type ProgramKtx struct {
Config *config.Config
WindowWidth int
WindowHeight int
AvailableHeight int
}
func (k *ProgramKtx) HeightUsed(height int) {
if k.AvailableHeight < height {
k.AvailableHeight -= k.AvailableHeight
} else {
k.AvailableHeight -= height
}
}
func New() *ProgramKtx {
return &ProgramKtx{}
}
func WithNewAvailableDimensions(ktx *ProgramKtx) *ProgramKtx {
ktx.AvailableHeight = ktx.WindowHeight
return ktx
}
package serdes
import (
"bytes"
"encoding/binary"
"encoding/json"
"fmt"
"github.com/linkedin/goavro/v2"
"github.com/pkg/errors"
"ktea/sradmin"
)
type GoAvroDeserializer struct {
sra sradmin.Client
}
type DesData struct {
Value string
Schema string
}
var ErrNoSchemaRegistry = errors.New("no schema registry configured")
func (d *GoAvroDeserializer) Deserialize(data []byte) (DesData, error) {
if data == nil || len(data) == 0 {
return DesData{}, nil
}
schemaId, isAvro := isAvroWithSchemaID(data)
if isAvro {
if d.sra == nil {
return DesData{}, fmt.Errorf("avro deserialization failed: %w", ErrNoSchemaRegistry)
}
if schema, err := d.getSchema(schemaId); err != nil {
return DesData{}, err
} else {
var codec *goavro.Codec
if codec, err = goavro.NewCodec(schema.Value); err != nil {
return DesData{}, err
}
deserData, _, err := codec.NativeFromBinary(data[5:])
if err != nil {
return DesData{}, err
}
jsonData, err := json.Marshal(deserData)
if err != nil {
return DesData{}, err
}
return DesData{string(jsonData), schema.Value}, nil
}
} else {
return DesData{Value: string(data)}, nil
}
}
func (d *GoAvroDeserializer) getSchema(schemaId int) (sradmin.Schema, error) {
var schema sradmin.Schema
switch msg := d.sra.GetSchemaById(schemaId).(type) {
case sradmin.GettingSchemaByIdMsg:
{
switch msg := msg.AwaitCompletion().(type) {
case sradmin.SchemaByIdReceived:
{
schema = msg.Schema
}
case sradmin.FailedToFetchLatestSchemaBySubject:
{
return sradmin.Schema{}, msg.Err
}
}
}
case sradmin.SchemaByIdReceived:
{
schema = msg.Schema
}
}
return schema, nil
}
func isAvroWithSchemaID(data []byte) (int, bool) {
if len(data) < 5 {
return -1, false
}
// Check the magic byte
if data[0] != 0x00 {
return -1, false
}
// Read the schema ID (4 bytes after the magic byte)
var schemaId int32
reader := bytes.NewReader(data[1:5])
if err := binary.Read(reader, binary.BigEndian, &schemaId); err != nil {
return -1, false
}
return int(schemaId), true
}
func NewAvroDeserializer(sra sradmin.Client) Deserializer {
return &GoAvroDeserializer{sra: sra}
}
package sradmin
import (
tea "github.com/charmbracelet/bubbletea"
"ktea/config"
)
type ConnCheckStartedMsg struct {
ConnOk chan bool
Err chan error
}
func (c *ConnCheckStartedMsg) AwaitCompletion() tea.Msg {
select {
case <-c.ConnOk:
return ConnCheckSucceededMsg{}
case err := <-c.Err:
return ConnCheckErrMsg{err}
}
}
func (s *DefaultSrClient) CheckConnection() tea.Msg {
connOkChan := make(chan bool)
errChan := make(chan error)
go s.doCheckConnection(connOkChan, errChan)
return ConnCheckStartedMsg{connOkChan, errChan}
}
func (s *DefaultSrClient) doCheckConnection(connOkChan chan bool, errChan chan error) {
maybeIntroduceLatency()
_, err := s.client.GetSubjects()
if err != nil {
errChan <- err
return
}
connOkChan <- true
}
// CheckSchemaRegistryConn implements ConnChecker.
// CheckSchemaRegistryConn checks if the Schema Registry is reachable and returns a tea.Msg to report status.
func CheckSchemaRegistryConn(c *config.SchemaRegistryConfig) tea.Msg {
client := New(c)
return client.CheckConnection()
}
package sradmin
import tea "github.com/charmbracelet/bubbletea"
type GlobalCompatibilityListedMsg struct {
Compatibility string
}
type GlobalCompatibilityListingErrorMsg struct {
Err error
}
type GlobalCompatibilityListingStartedMsg struct {
compatibility chan string
err chan error
}
func (msg *GlobalCompatibilityListingStartedMsg) AwaitCompletion() tea.Msg {
select {
case compatibility := <-msg.compatibility:
return GlobalCompatibilityListedMsg{compatibility}
case err := <-msg.err:
return GlobalCompatibilityListingErrorMsg{err}
}
}
func (s *DefaultSrClient) ListGlobalCompatibility() tea.Msg {
maybeIntroduceLatency()
compatibilityChan := make(chan string)
errChan := make(chan error)
go s.doListGlobalCompatibility(compatibilityChan, errChan)
return GlobalCompatibilityListingStartedMsg{
compatibility: compatibilityChan,
err: errChan,
}
}
func (s *DefaultSrClient) doListGlobalCompatibility(
compatibilityChan chan string,
errChan chan error,
) {
compatibility, err := s.client.GetGlobalCompatibilityLevel()
if err != nil {
errChan <- err
return
}
compatibilityChan <- compatibility.String()
}
//go:build !dev
package sradmin
func maybeIntroduceLatency() {
}
package sradmin
import tea "github.com/charmbracelet/bubbletea"
type SchemaDeletionStartedMsg struct {
Subject string
Version int
Deleted chan bool
Err chan error
}
type SchemaDeletedMsg struct {
Version int
}
type SchemaDeletionErrMsg struct {
Err error
}
func (m *SchemaDeletionStartedMsg) AwaitCompletion() tea.Msg {
select {
case <-m.Deleted:
return SchemaDeletedMsg{m.Version}
case err := <-m.Err:
return SchemaDeletionErrMsg{Err: err}
}
}
func (s *DefaultSrClient) DeleteSchema(subject string, version int) tea.Msg {
deletedChan := make(chan bool)
errChan := make(chan error)
go s.doDeleteSchema(subject, version, deletedChan, errChan)
return SchemaDeletionStartedMsg{
subject,
version,
deletedChan,
errChan,
}
}
func (s *DefaultSrClient) doDeleteSchema(
subject string,
version int,
deletedChan chan bool,
errChan chan error,
) {
err := s.client.DeleteSubjectByVersion(subject, version, true)
if err != nil {
errChan <- err
} else {
deletedChan <- true
}
}
package sradmin
import (
tea "github.com/charmbracelet/bubbletea"
"strconv"
)
type SchemaFetcher interface {
GetSchemaById(id int) tea.Msg
}
type GettingSchemaByIdMsg struct {
SchemaChan chan Schema
ErrChan chan error
}
type SchemaByIdReceived struct {
Schema Schema
}
type FailedToFetchLatestSchemaBySubject struct {
Err error
}
func (msg *GettingSchemaByIdMsg) AwaitCompletion() tea.Msg {
select {
case schema := <-msg.SchemaChan:
return SchemaByIdReceived{
Schema: schema,
}
case err := <-msg.ErrChan:
return FailedToFetchLatestSchemaBySubject{Err: err}
}
}
func (s *DefaultSrClient) GetSchemaById(id int) tea.Msg {
if schema, ok := s.schemaCache[id]; ok {
return SchemaByIdReceived{Schema: schema}
}
schemaChan := make(chan Schema)
errChan := make(chan error)
go s.doGetSchemaById(id, schemaChan, errChan)
return GettingSchemaByIdMsg{
SchemaChan: schemaChan,
ErrChan: errChan,
}
}
func (s *DefaultSrClient) doGetSchemaById(id int, schemaChan chan Schema, errChan chan error) {
schema, err := s.client.GetSchema(id)
if err != nil {
errChan <- err
return
}
schemaChan <- Schema{
Id: strconv.Itoa(schema.ID()),
Value: schema.Schema(),
Version: schema.Version(),
Err: nil,
}
}
package sradmin
import (
"encoding/base64"
tea "github.com/charmbracelet/bubbletea"
"github.com/riferrei/srclient"
"ktea/config"
"net/http"
"sync"
)
type DefaultSrClient struct {
client *srclient.SchemaRegistryClient
subjects []Subject
mu sync.RWMutex
schemaCache map[int]Schema
}
type Client interface {
SubjectDeleter
SubjectLister
SchemaCreator
VersionLister
SchemaFetcher
GlobalCompatibilityLister
LatestSchemaBySubjectFetcher
SchemaDeleter
}
type ConnCheckSucceededMsg struct{}
type ConnCheckErrMsg struct {
Err error
}
// ConnChecker is a function that checks a Schema Registry connection and returns a tea.Msg.
type ConnChecker func(c *config.SchemaRegistryConfig) tea.Msg
type SchemaCreationStartedMsg struct {
created chan bool
err chan error
}
type SchemaCreatedMsg struct{}
type SchemaCreationErrMsg struct {
Err error
}
func (msg *SchemaCreationStartedMsg) AwaitCompletion() tea.Msg {
select {
case <-msg.created:
return SchemaCreatedMsg{}
case err := <-msg.err:
return SchemaCreationErrMsg{err}
}
}
func (s *DefaultSrClient) CreateSchema(details SubjectCreationDetails) tea.Msg {
createdChan := make(chan bool)
errChan := make(chan error)
go s.doCreateSchema(details, createdChan, errChan)
return SchemaCreationStartedMsg{
createdChan,
errChan,
}
}
func (s *DefaultSrClient) doCreateSchema(details SubjectCreationDetails, createdChan chan bool, errChan chan error) {
maybeIntroduceLatency()
_, err := s.client.CreateSchema(details.Subject, details.Schema, srclient.Avro)
if err != nil {
errChan <- err
return
}
createdChan <- true
}
func createHttpClient(registry *config.SchemaRegistryConfig) *http.Client {
auth := registry.Username + ":" + registry.Password
authHeader := "Basic " + base64.StdEncoding.EncodeToString([]byte(auth))
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
}
client := &http.Client{
Transport: roundTripperWithAuth{
baseTransport: transport,
authHeader: authHeader,
},
}
return client
}
type roundTripperWithAuth struct {
baseTransport http.RoundTripper
authHeader string
}
// RoundTrip adds the Authorization header to every request
func (r roundTripperWithAuth) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Set("Authorization", r.authHeader)
return r.baseTransport.RoundTrip(req)
}
func New(registryConfig *config.SchemaRegistryConfig) *DefaultSrClient {
client := createHttpClient(registryConfig)
return &DefaultSrClient{
client: srclient.NewSchemaRegistryClient(registryConfig.Url, srclient.WithClient(client)),
}
}
package sradmin
import tea "github.com/charmbracelet/bubbletea"
type SubjectDeletedMsg struct {
SubjectName string
}
type SubjectDeletionErrorMsg struct {
Err error
}
type SubjectDeletionStartedMsg struct {
Subject string
Deleted chan bool
Err chan error
}
func (msg *SubjectDeletionStartedMsg) AwaitCompletion() tea.Msg {
select {
case <-msg.Deleted:
return SubjectDeletedMsg{msg.Subject}
case err := <-msg.Err:
return SubjectDeletionErrorMsg{err}
}
}
func (s *DefaultSrClient) HardDeleteSubject(subject string) tea.Msg {
deletedChan := make(chan bool)
errChan := make(chan error)
go s.doDeleteSubject(subject, true, deletedChan, errChan)
return SubjectDeletionStartedMsg{
subject,
deletedChan,
errChan,
}
}
func (s *DefaultSrClient) SoftDeleteSubject(subject string) tea.Msg {
deletedChan := make(chan bool)
errChan := make(chan error)
go s.doDeleteSubject(subject, false, deletedChan, errChan)
return SubjectDeletionStartedMsg{
subject,
deletedChan,
errChan,
}
}
func (s *DefaultSrClient) doDeleteSubject(
subject string,
permanent bool,
deletedChan chan bool,
errChan chan error,
) {
maybeIntroduceLatency()
err := s.client.DeleteSubject(subject, permanent)
if err != nil {
errChan <- err
return
} else {
deletedChan <- true
return
}
}
package sradmin
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/log"
"slices"
"sync"
)
type SubjectsListedMsg struct {
Subjects []Subject
}
type SubjectListingErrorMsg struct {
Err error
}
type SubjectListingStartedMsg struct {
subjects chan []Subject
err chan error
}
// AwaitCompletion return
// a SubjectsListedMsg upon success
// or SubjectListingErrorMsg upon failure.
func (msg *SubjectListingStartedMsg) AwaitCompletion() tea.Msg {
select {
case subjects := <-msg.subjects:
return SubjectsListedMsg{subjects}
case err := <-msg.err:
log.Error("Failed to fetch subjects", "err", err)
return SubjectListingErrorMsg{err}
}
}
func (s *DefaultSrClient) ListSubjects() tea.Msg {
subjectsChan := make(chan []Subject)
errChan := make(chan error)
go s.doListSubject(subjectsChan, errChan)
return SubjectListingStartedMsg{subjectsChan, errChan}
}
type Subject struct {
Name string
Versions []int
Compatibility string
Deleted bool
}
func (s *Subject) LatestVersion() int {
return slices.Max(s.Versions)
}
func (s *DefaultSrClient) doListSubject(
subjectsChan chan []Subject,
errChan chan error,
) {
maybeIntroduceLatency()
var (
subjectNames []string
err error
)
subjectNames, err = s.client.GetSubjectsIncludingDeleted()
if err != nil {
errChan <- err
return
}
subjects := make([]Subject, len(subjectNames))
active, _ := s.client.GetSubjects()
for i, name := range subjectNames {
deleted := !slices.Contains(active, name)
subjects[i] = Subject{Name: name, Deleted: deleted}
}
versionResults := make([][]int, len(subjects))
compResults := make([]string, len(subjects))
var wg sync.WaitGroup
errs := make(chan error, len(subjects)*2) // buffer for errors
for i, subj := range subjects {
// Version can only be fetched if the subject is not deleted
if !subj.Deleted {
wg.Add(1)
go func(i int, name string) {
defer wg.Done()
versions, err := s.client.GetSchemaVersions(name)
if err != nil {
errs <- fmt.Errorf("get versions %s: %w", name, err)
return
}
versionResults[i] = versions
}(i, subj.Name)
}
wg.Add(1)
go func(i int, name string) {
defer wg.Done()
comp, err := s.client.GetCompatibilityLevel(name, true)
if err != nil {
errs <- fmt.Errorf("get compatibility %s: %w", name, err)
return
}
compResults[i] = comp.String()
}(i, subj.Name)
}
wg.Wait()
close(errs)
// return first error if any
for e := range errs {
if e != nil {
errChan <- e
return
}
}
for i := range subjects {
subjects[i].Versions = versionResults[i]
subjects[i].Compatibility = compResults[i]
}
s.mu.Lock()
s.subjects = subjects
s.mu.Unlock()
subjectsChan <- subjects
}
package sradmin
import (
tea "github.com/charmbracelet/bubbletea"
"strconv"
"sync"
)
type Schema struct {
Id string
Value string
Version int
Err error
}
type SchemasListed struct {
Schemas []Schema
}
type SchemaListingStarted struct {
schemaChan chan Schema
versionCount int
}
func (s *SchemaListingStarted) AwaitCompletion() tea.Msg {
var schemas []Schema
count := 0
for count < s.versionCount {
select {
case schema, ok := <-s.schemaChan:
if !ok {
// Channel is closed; exit loop
return SchemasListed{Schemas: schemas}
}
schemas = append(schemas, schema)
count++
}
}
return SchemasListed{Schemas: schemas}
}
func (s *DefaultSrClient) ListVersions(subject string, versions []int) tea.Msg {
schemaChan := make(chan Schema, len(versions))
var wg sync.WaitGroup
wg.Add(len(versions))
for _, version := range versions {
go func(version int) {
defer wg.Done()
schema, err := s.client.GetSchemaByVersion(subject, version)
if err == nil {
schemaChan <- Schema{
Id: strconv.Itoa(schema.ID()),
Value: schema.Schema(),
Version: version,
}
} else {
schemaChan <- Schema{
Err: err,
Version: version,
}
}
}(version)
}
go func() {
wg.Wait()
close(schemaChan)
}()
return SchemaListingStarted{
schemaChan: schemaChan, versionCount: len(versions),
}
}
package styles
import (
"fmt"
"ktea/kontext"
"strings"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/lipgloss"
)
var Env EnvStyle
var Tab TabStyle
var Statusbar StatusBarStyle
var Table TableStyle
var CmdBar lipgloss.Style
var Form = lipgloss.NewStyle().
PaddingLeft(1).
PaddingTop(1)
var TextViewPort = lipgloss.NewStyle().
PaddingLeft(1).
BorderStyle(lipgloss.RoundedBorder())
var Notifier NotifierStyle
var clusterColors = map[string]string{
ColorRed: ColorWhite,
ColorYellow: ColorBlack,
ColorGreen: ColorBlack,
ColorOrange: ColorBlack,
ColorPurple: ColorWhite,
ColorBlue: ColorWhite,
}
const ColorRed = "#FF0000"
const ColorGreen = "#00FF00"
const ColorBlue = "#0000FF"
const ColorOrange = "#FFA500"
const ColorPurple = "#800080"
const ColorIndigo = "#7571F9"
const ColorYellow = "#FFFF00"
const ColorWhite = "#FFFFFF"
const ColorBlack = "#000000"
const ColorPink = "205"
const ColorLightPink = "132"
const ColorGrey = "#C1C1C1"
const ColorDarkGrey = "#343433"
const ColorFocusBorder = "#F5F5F5"
const ColorBlurBorder = "235"
type NotifierStyle struct {
Spinner lipgloss.Style
Success lipgloss.Style
Error lipgloss.Style
}
type TableStyle struct {
Focus lipgloss.Style
Blur lipgloss.Style
Styles table.Styles
}
type StatusBarStyle struct {
style lipgloss.Style
Indicator lipgloss.Style
Text lipgloss.Style
BindingName lipgloss.Style
Shortcuts lipgloss.Style
Key lipgloss.Style
Spacer lipgloss.Style
cluster lipgloss.Style
}
type BorderPosition int
const (
TopLeftBorder BorderPosition = iota
TopMiddleBorder
TopRightBorder
BottomLeftBorder
BottomMiddleBorder
BottomRightBorder
)
type EmbeddedTextFunc func(active bool) string
func EmbeddedBorderText(
keyLabel string,
valueLabel string,
) EmbeddedTextFunc {
return func(active bool) string {
var (
colorLabel lipgloss.Color
colorCount lipgloss.Color
)
if active {
colorLabel = ColorWhite
colorCount = ColorPink
} else {
colorLabel = ColorGrey
colorCount = ColorLightPink
}
var renderedValueLabel string
if valueLabel == "" {
renderedValueLabel = ""
} else {
renderedValueLabel = ":" + lipgloss.NewStyle().
Foreground(colorCount).
Bold(true).
Render(fmt.Sprintf(" %s", valueLabel))
}
return lipgloss.NewStyle().
Foreground(colorLabel).
Bold(true).
Render(fmt.Sprintf("[ %s", keyLabel)) + renderedValueLabel +
lipgloss.NewStyle().
Foreground(colorLabel).
Bold(true).
Render(" ]")
}
}
// Deprecated: use border.Model
// Borderize creates a border around content with optional embedded text in different positions
func Borderize(content string, active bool, embeddedText map[BorderPosition]EmbeddedTextFunc) string {
if embeddedText == nil {
embeddedText = make(map[BorderPosition]EmbeddedTextFunc)
}
borderColor := ColorFocusBorder
if !active {
borderColor = ColorBlurBorder
}
style := lipgloss.NewStyle().Foreground(lipgloss.Color(borderColor))
// Split content into lines to get the maximum width
lines := strings.Split(content, "\n")
maxWidth := 0
for _, line := range lines {
if w := lipgloss.Width(line); w > maxWidth {
maxWidth = w
}
}
encloseText := func(text string) string {
if text != "" {
return style.Render(" " + text + " ")
}
return text
}
buildBorderLine := func(leftText, middleText, rightText, leftCorner, border, rightCorner string) string {
leftText = encloseText(leftText)
middleText = encloseText(middleText)
rightText = encloseText(rightText)
// Calculate remaining space for borders
remaining := maxWidth - lipgloss.Width(leftText) - lipgloss.Width(middleText) - lipgloss.Width(rightText)
if remaining < 0 {
remaining = 0
}
leftBorderLen := (remaining / 2)
rightBorderLen := remaining - leftBorderLen
// Build the border line
borderLine := leftText +
style.Render(strings.Repeat(border, leftBorderLen)) +
middleText +
style.Render(strings.Repeat(border, rightBorderLen)) +
rightText
// Add corners
return style.Render(leftCorner) + borderLine + style.Render(rightCorner)
}
// Create the bordered content
topBorder := buildBorderLine(
getTextOrEmpty(embeddedText[TopLeftBorder], active),
getTextOrEmpty(embeddedText[TopMiddleBorder], active),
getTextOrEmpty(embeddedText[TopRightBorder], active),
"╭", "─", "╮",
)
// Create side borders for content
borderedLines := make([]string, len(lines))
for i, line := range lines {
lineWidth := lipgloss.Width(line)
var paddedLine string
if lineWidth < maxWidth {
paddedLine = line + strings.Repeat(" ", maxWidth-lineWidth)
} else if lineWidth > maxWidth {
paddedLine = lipgloss.NewStyle().MaxWidth(maxWidth).Render(line)
} else {
paddedLine = line
}
borderedLines[i] = style.Render("│") + paddedLine + style.Render("│")
}
borderedContent := strings.Join(borderedLines, "\n")
// Create bottom border
bottomBorder := buildBorderLine(
getTextOrEmpty(embeddedText[BottomLeftBorder], active),
getTextOrEmpty(embeddedText[BottomMiddleBorder], active),
getTextOrEmpty(embeddedText[BottomRightBorder], active),
"╰", "─", "╯",
)
// Final content with borders
return topBorder + "\n" + borderedContent + "\n" + bottomBorder
}
func getTextOrEmpty(embeddedText EmbeddedTextFunc, active bool) string {
if embeddedText == nil {
return ""
}
return embeddedText(active)
}
func CenterText(width int, height int) lipgloss.Style {
return lipgloss.NewStyle().
Width(width - 2).
Height(height).
AlignVertical(lipgloss.Center).
AlignHorizontal(lipgloss.Center).
Bold(true).
Foreground(lipgloss.Color(ColorPink))
}
func CmdBarWithWidth(width int) lipgloss.Style {
return CmdBar.Width(width)
}
func FG(color lipgloss.Color) lipgloss.Style {
return lipgloss.NewStyle().
Foreground(color)
}
func (s *StatusBarStyle) Cluster(color string) lipgloss.Style {
return s.cluster.
Background(lipgloss.Color(color)).
Foreground(lipgloss.Color(clusterColors[color]))
}
type TabStyle struct {
Tab lipgloss.Style
ActiveTab lipgloss.Style
}
type EnvStyle struct {
Colors struct {
Red lipgloss.Style
Green lipgloss.Style
Blue lipgloss.Style
Orange lipgloss.Style
Yellow lipgloss.Style
Purple lipgloss.Style
}
}
func (s *StatusBarStyle) Render(c *kontext.ProgramKtx, strs ...string) string {
return s.style.Render(strs...)
}
func init() {
{
clusterColors = map[string]string{
ColorRed: ColorWhite,
ColorGreen: ColorBlack,
ColorBlue: ColorWhite,
ColorOrange: ColorBlack,
ColorPurple: ColorWhite,
ColorYellow: ColorBlack,
}
}
{
tabStyle := TabStyle{}
activeTabBorder := lipgloss.Border{
Top: "─",
Bottom: " ",
Left: "│",
Right: "│",
TopLeft: "╭",
TopRight: "╮",
BottomLeft: "┘",
BottomRight: "└",
}
tabBorder := lipgloss.Border{
Top: "─",
Bottom: "─",
Left: "│",
Right: "│",
TopLeft: "╭",
TopRight: "╮",
BottomLeft: "┴",
BottomRight: "┴",
}
tabStyle.Tab = lipgloss.NewStyle().
Padding(0, 1).
Foreground(lipgloss.Color("#AAAAAA")).
Border(tabBorder)
tabStyle.ActiveTab = lipgloss.NewStyle().
Padding(0, 1).
Border(activeTabBorder).
Foreground(lipgloss.Color(ColorPink)).
Bold(true)
Tab = tabStyle
}
{
statusBarStyle := StatusBarStyle{}
statusBarStyle.style = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: ColorDarkGrey, Dark: "#C1C6B2"}).
Background(lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#353533"})
statusBarStyle.Indicator = lipgloss.NewStyle().
Inherit(statusBarStyle.style).
Foreground(lipgloss.Color(ColorBlack)).
Background(lipgloss.Color(ColorPink)).
Bold(true).
Padding(0, 2)
statusBarStyle.cluster = lipgloss.NewStyle().
Inherit(statusBarStyle.style).
Foreground(lipgloss.Color("#FFFFFF")).
Background(lipgloss.Color(ColorRed)).
Bold(true).
Padding(0, 4)
statusBarStyle.Text = lipgloss.NewStyle().
MarginTop(1).
PaddingLeft(0).
PaddingRight(5)
statusBarStyle.BindingName = lipgloss.NewStyle().
Foreground(lipgloss.Color(ColorYellow))
statusBarStyle.Key = lipgloss.NewStyle().
Foreground(lipgloss.Color(ColorWhite))
statusBarStyle.Spacer = lipgloss.NewStyle().
Inherit(statusBarStyle.style).
PaddingLeft(1)
statusBarStyle.Shortcuts = lipgloss.NewStyle().
PaddingLeft(2)
Statusbar = statusBarStyle
}
{
CmdBar = lipgloss.NewStyle().
MarginTop(0).
MarginBottom(0).
BorderStyle(lipgloss.ThickBorder())
}
{
Notifier.Spinner = lipgloss.NewStyle().
MarginTop(0).
MarginBottom(0).
MarginLeft(2).
Height(1)
Notifier.Success = lipgloss.NewStyle().
MarginTop(0).
MarginBottom(0).
MarginLeft(2).
Height(1).
Foreground(lipgloss.Color(ColorGreen)).
Bold(true)
Notifier.Error = lipgloss.NewStyle().
MarginTop(0).
MarginBottom(0).
MarginLeft(2).
Height(1).
Foreground(lipgloss.Color(ColorRed)).
Bold(true)
}
{
envStyle := EnvStyle{}
envStyle.Colors.Red = lipgloss.NewStyle().
Foreground(lipgloss.Color(clusterColors[ColorRed])).
Background(lipgloss.Color(ColorRed)).
PaddingLeft(1).
PaddingRight(1).
Width(10).
Bold(true)
envStyle.Colors.Green = lipgloss.NewStyle().
Foreground(lipgloss.Color(clusterColors[ColorGreen])).
Background(lipgloss.Color(ColorGreen)).
PaddingLeft(1).
PaddingRight(1).
Width(10).
Bold(true)
envStyle.Colors.Blue = lipgloss.NewStyle().
Foreground(lipgloss.Color(clusterColors[ColorBlue])).
Background(lipgloss.Color(ColorBlue)).
PaddingLeft(1).
PaddingRight(1).
Width(10).
Bold(true)
envStyle.Colors.Orange = lipgloss.NewStyle().
Foreground(lipgloss.Color(clusterColors[ColorOrange])).
Background(lipgloss.Color(ColorOrange)).
PaddingLeft(1).
PaddingRight(1).
Width(10).
Bold(true)
envStyle.Colors.Purple = lipgloss.NewStyle().
Foreground(lipgloss.Color(clusterColors[ColorPurple])).
Background(lipgloss.Color(ColorPurple)).
PaddingLeft(1).
PaddingRight(1).
Width(10).
Bold(true)
envStyle.Colors.Yellow = lipgloss.NewStyle().
Foreground(lipgloss.Color(clusterColors[ColorYellow])).
Background(lipgloss.Color(ColorYellow)).
PaddingLeft(1).
PaddingRight(1).
Width(10).
Bold(true)
Env = envStyle
}
{
blur := lipgloss.NewStyle().
Padding(0).
Margin(0)
focus := lipgloss.NewStyle().
Padding(0).
Margin(0).
Inherit(blur)
styles := table.DefaultStyles()
styles.Header = styles.Header.
BorderBottom(true).
BorderTop(false).
BorderLeft(false).
BorderRight(false).
BorderStyle(lipgloss.Border{
Bottom: "─",
}).
Bold(false)
styles.Selected = styles.Selected.
Foreground(lipgloss.Color("#000000")).
Background(lipgloss.Color(ColorPink)).
Bold(true)
Table = TableStyle{
Focus: focus,
Blur: blur,
Styles: styles,
}
}
}
package clipper
import "github.com/atotto/clipboard"
type Writer interface {
Write(text string) error
}
type DefaultClipper struct {
}
func (d *DefaultClipper) Write(text string) error {
err := clipboard.WriteAll(text)
if err != nil {
return err
}
return nil
}
func New() Writer {
return &DefaultClipper{}
}
package border
import (
"fmt"
"github.com/charmbracelet/lipgloss"
"ktea/styles"
"strings"
)
const (
TopLeftBorder Position = iota
TopMiddleBorder
TopRightBorder
BottomLeftBorder
BottomMiddleBorder
BottomRightBorder
)
type Model struct {
Focused bool
tabs []Tab
onTabChanged OnTabChangedFunc
textByPos map[Position]TextFunc
activeTabIdx int
activeColor lipgloss.Color
inActiveColor lipgloss.Color
paddingTop string
}
type TabLabel string
type Tab struct {
Title string
TabLabel
}
type Position int
type TextFunc func(m *Model) string
type OnTabChangedFunc func(newTab string, m *Model)
type Option func(m *Model)
func (m *Model) View(content string) string {
return m.borderize(m.paddingTop + content)
}
func (m *Model) encloseText(text string) string {
if text != "" {
return " " + text + " "
}
return text
}
func (m *Model) buildBorderLine(
style lipgloss.Style,
maxWidth int,
leftText, middleText, rightText, leftCorner, border, rightCorner string,
) string {
leftText = m.encloseText(leftText)
middleText = m.encloseText(middleText)
rightText = m.encloseText(rightText)
// Calculate remaining space for borders
remaining := maxWidth - lipgloss.Width(leftText) - lipgloss.Width(middleText) - lipgloss.Width(rightText)
if remaining < 0 {
remaining = 0
}
leftBorderLen := remaining / 2
rightBorderLen := remaining - leftBorderLen
// Build the borderline
borderLine := leftText +
style.Render(strings.Repeat(border, leftBorderLen)) +
middleText +
style.Render(strings.Repeat(border, rightBorderLen)) +
rightText
// Add corners
return style.Render(leftCorner) + borderLine + style.Render(rightCorner)
}
func (m *Model) borderize(content string) string {
borderColor := styles.ColorFocusBorder
if !m.Focused {
borderColor = styles.ColorBlurBorder
}
style := lipgloss.NewStyle().Foreground(lipgloss.Color(borderColor))
// Split content into lines to get the maximum width
lines := strings.Split(content, "\n")
maxWidth := 0
for _, line := range lines {
if w := lipgloss.Width(line); w > maxWidth {
maxWidth = w
}
}
// Create the bordered content
topBorder := m.buildBorderLine(
style,
maxWidth,
m.getTextOrEmpty(m.textByPos[TopLeftBorder]),
m.getTextOrEmpty(m.textByPos[TopMiddleBorder]),
m.getTextOrEmpty(m.textByPos[TopRightBorder]),
"╭", "─", "╮",
)
// Create side borders for content
borderedLines := make([]string, len(lines))
for i, line := range lines {
lineWidth := lipgloss.Width(line)
var paddedLine string
if lineWidth < maxWidth {
paddedLine = line + strings.Repeat(" ", maxWidth-lineWidth)
} else if lineWidth > maxWidth {
paddedLine = lipgloss.NewStyle().MaxWidth(maxWidth).Render(line)
} else {
paddedLine = line
}
borderedLines[i] = style.Render("│") + paddedLine + style.Render("│")
}
borderedContent := strings.Join(borderedLines, "\n")
// Create bottom border
bottomBorder := m.buildBorderLine(
style,
maxWidth,
m.getTextOrEmpty(m.textByPos[BottomLeftBorder]),
m.getTextOrEmpty(m.textByPos[BottomMiddleBorder]),
m.getTextOrEmpty(m.textByPos[BottomRightBorder]),
"╰", "─", "╯",
)
// Final content with borders
return topBorder + "\n" + borderedContent + "\n" + bottomBorder
}
func (m *Model) getTextOrEmpty(embeddedText TextFunc) string {
if embeddedText == nil {
return ""
}
return embeddedText(m)
}
func (m *Model) NextTab() {
if m.activeTabIdx == len(m.tabs)-1 {
m.activeTabIdx = 0
} else {
m.activeTabIdx++
}
}
func (m *Model) GoTo(label TabLabel) {
for i, tab := range m.tabs {
if tab.TabLabel == label {
m.activeTabIdx = i
break
}
}
}
func (m *Model) WithInActiveColor(c lipgloss.Color) {
m.inActiveColor = c
}
func (m *Model) ActiveTab() TabLabel {
return m.tabs[m.activeTabIdx].TabLabel
}
func Title(title string, active bool) string {
return KeyValueTitle(title, "", active)
}
func KeyValueTitle(
keyLabel string,
valueLabel string,
active bool,
) string {
var (
colorLabel lipgloss.Color
colorCount lipgloss.Color
)
if active {
colorLabel = styles.ColorWhite
colorCount = styles.ColorPink
} else {
colorLabel = styles.ColorGrey
colorCount = styles.ColorLightPink
}
var renderedValueLabel string
if valueLabel == "" {
renderedValueLabel = ""
} else {
renderedValueLabel = ":" + lipgloss.NewStyle().
Foreground(colorCount).
Bold(true).
Render(fmt.Sprintf(" %s", valueLabel))
}
return lipgloss.NewStyle().
Foreground(colorLabel).
Bold(true).
Render(fmt.Sprintf("[ %s", keyLabel)) + renderedValueLabel +
lipgloss.NewStyle().
Foreground(colorLabel).
Bold(true).
Render(" ]")
}
// WithTitle adds a right aligned top and bottom title string
func WithTitle(title string) Option {
return func(m *Model) {
m.textByPos[TopRightBorder] = func(_ *Model) string {
return title
}
m.textByPos[BottomRightBorder] = func(_ *Model) string {
return title
}
}
}
// WithTitleFn adds the string result of the function
// as a right top and bottom aligned title string
func WithTitleFn(titleFunc func() string) Option {
return func(m *Model) {
m.textByPos[TopRightBorder] = func(_ *Model) string {
return titleFunc()
}
m.textByPos[BottomRightBorder] = func(_ *Model) string {
return titleFunc()
}
}
}
func WithTabs(tabs ...Tab) Option {
return func(m *Model) {
if len(tabs) == 0 {
return
}
m.tabs = tabs
m.textByPos[TopLeftBorder] = func(m *Model) string {
var renderedTabs string
for i, tab := range tabs {
if m.activeTabIdx == i {
renderedTabs += lipgloss.NewStyle().
Bold(true).
Background(m.activeColor).
Padding(0, 1).
Render(tab.Title)
} else {
renderedTabs += lipgloss.NewStyle().
Padding(0, 1).
Foreground(m.inActiveColor).
Render(tab.Title)
}
}
return fmt.Sprintf("|%s|", renderedTabs)
}
}
}
func WithInactiveColor(c lipgloss.Color) Option {
return func(m *Model) {
m.inActiveColor = c
}
}
func WithOnTabChanged(o OnTabChangedFunc) Option {
return func(m *Model) {
m.onTabChanged = o
}
}
func WithInnerPaddingTop() Option {
return func(m *Model) {
m.paddingTop = "\n"
}
}
func New(options ...Option) *Model {
m := &Model{}
m.textByPos = make(map[Position]TextFunc)
m.Focused = true
m.activeColor = styles.ColorPurple
for _, option := range options {
option(m)
}
return m
}
package chips
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"ktea/kontext"
"ktea/styles"
"ktea/ui"
"strings"
)
type Model struct {
label string
elems []string
activateElem int
selectedIdx int
}
func (m *Model) View(_ *kontext.ProgramKtx, _ *ui.Renderer) string {
builder := strings.Builder{}
builder.WriteString(m.label + ":")
for i, elem := range m.elems {
var (
style lipgloss.Style
bgColor lipgloss.Color
)
if m.activateElem == i {
if m.selectedIdx == i {
bgColor = styles.ColorPink
} else {
bgColor = styles.ColorWhite
}
style = lipgloss.NewStyle().
Background(bgColor).
Foreground(lipgloss.Color(styles.ColorBlack)).
Bold(true)
elem = fmt.Sprintf("«%s»", elem)
} else if m.selectedIdx == i {
style = lipgloss.NewStyle().
Background(lipgloss.Color(styles.ColorPink)).
Foreground(lipgloss.Color(styles.ColorBlack))
elem = fmt.Sprintf(" %s ", elem)
} else {
style = lipgloss.NewStyle().
Background(lipgloss.Color(styles.ColorGrey)).
Foreground(lipgloss.Color(styles.ColorBlack))
elem = fmt.Sprintf(" %s ", elem)
}
builder.WriteString(
style.
Padding(0, 1).
MarginLeft(1).
MarginRight(0).
Render(elem),
)
}
return builder.String()
}
func (m *Model) Update(msg tea.Msg) tea.Cmd {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "h", "left":
m.prevElem()
case "l", "right":
m.nextElem()
case "enter":
m.activateElem = m.selectedIdx
}
}
return nil
}
func (m *Model) ActivateByLabel(label string) {
for i, elem := range m.elems {
if elem == label {
m.activateElem = i
m.selectedIdx = i
}
}
}
func (m *Model) prevElem() {
if m.selectedIdx >= 1 {
m.selectedIdx--
}
}
func (m *Model) nextElem() {
if m.selectedIdx < len(m.elems)-1 {
m.selectedIdx++
}
}
func (m *Model) SelectedLabel() string {
if m == nil || len(m.elems) == 0 {
return ""
}
return m.elems[m.selectedIdx]
}
func New(
label string,
elems ...string,
) *Model {
return &Model{
label,
elems,
0,
0,
}
}
package cmdbar
import (
"fmt"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"ktea/kontext"
"ktea/styles"
"ktea/ui"
"ktea/ui/components/statusbar"
"strings"
)
type DeleteMsgFn[T any] func(T) string
type DeleteFn[T any] func(T) tea.Cmd
type ValidateFn[T any] func(T) (bool, tea.Cmd)
type DeleteCmdBar[T any] struct {
active bool
deleteConfirm *huh.Confirm
msg string
deleteValue T
deleteMsgFunc DeleteMsgFn[T]
deleteFunc DeleteFn[T]
validateFn ValidateFn[T]
deleteKey string
}
type Option[T any] func(bar *DeleteCmdBar[T])
func WithDeleteKey[T any](key string) Option[T] {
return func(bar *DeleteCmdBar[T]) {
bar.deleteKey = key
}
}
func WithValidateFn[T any](vFn ValidateFn[T]) Option[T] {
return func(bar *DeleteCmdBar[T]) {
bar.validateFn = vFn
}
}
func (s *DeleteCmdBar[any]) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
s.deleteConfirm.Title(lipgloss.NewStyle().
Foreground(lipgloss.Color("#FAFAFA")).
Render(lipgloss.NewStyle().MarginRight(2).Render("🗑️ " + s.deleteMsgFunc(s.deleteValue)))).
WithButtonAlignment(lipgloss.Left).
Focus()
s.deleteConfirm.WithTheme(huh.ThemeCharm())
return renderer.RenderWithStyle(s.deleteConfirm.View(), styles.CmdBarWithWidth(ktx.WindowWidth-BorderedPadding))
}
func (s *DeleteCmdBar[any]) IsFocussed() bool {
return s.active
}
func (s *DeleteCmdBar[any]) Shortcuts() []statusbar.Shortcut {
if s.active {
return []statusbar.Shortcut{
{"Confirm", "enter"},
{"Select Cancel", "c"},
{"Select Delete", "d"},
{"Cancel", fmt.Sprintf("esc/%s", strings.ToUpper(s.deleteKey))},
}
}
return nil
}
func (s *DeleteCmdBar[any]) Update(msg tea.Msg) (bool, tea.Msg, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case s.deleteKey:
s.active = !s.active
return s.active, nil, nil
case "esc":
s.active = false
return s.active, nil, nil
case "enter":
if s.validateFn != nil {
valid, cmd := s.validateFn(s.deleteValue)
if !valid {
return s.active, nil, cmd
}
}
if s.deleteConfirm.GetValue().(bool) {
s.deleteConfirm = newDeleteConfirm()
return s.active, nil, s.deleteFunc(s.deleteValue)
} else {
s.active = false
return s.active, nil, nil
}
}
}
confirm, cmd := s.deleteConfirm.Update(msg)
if cmd != nil {
// if msg has been handled do not propagate it
msg = nil
}
if c, ok := confirm.(*huh.Confirm); ok {
s.deleteConfirm = c
}
return s.active, msg, nil
}
func (s *DeleteCmdBar[T]) Delete(d T) {
s.deleteValue = d
}
func (s *DeleteCmdBar[any]) Hide() {
s.active = false
}
func newDeleteConfirm() *huh.Confirm {
return huh.NewConfirm().
Inline(true).
Affirmative("Delete!").
Negative("Cancel.").
WithKeyMap(&huh.KeyMap{
Confirm: huh.ConfirmKeyMap{
Submit: key.NewBinding(key.WithKeys("enter")),
Toggle: key.NewBinding(key.WithKeys("h", "l", "right", "left")),
Accept: key.NewBinding(key.WithKeys("d")),
Reject: key.NewBinding(key.WithKeys("c")),
},
}).(*huh.Confirm)
}
func NewDeleteCmdBar[T any](
deleteMsgFunc DeleteMsgFn[T],
deleteFunc DeleteFn[T],
options ...Option[T],
) *DeleteCmdBar[T] {
bar := DeleteCmdBar[T]{
deleteFunc: deleteFunc,
deleteMsgFunc: deleteMsgFunc,
deleteConfirm: newDeleteConfirm(),
deleteKey: "f2",
}
for _, o := range options {
o(&bar)
}
return &bar
}
package cmdbar
import (
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"ktea/kontext"
"ktea/styles"
"ktea/ui"
"ktea/ui/components/notifier"
"ktea/ui/components/statusbar"
"reflect"
)
// NotificationHandler handles a specific notification and
// returns if the Cmdbar should be considered active or not and an optional tea.Cmd to be executed
type NotificationHandler[T any] func(T, *notifier.Model) (bool, tea.Cmd)
type NotifierCmdBar struct {
active bool
Notifier *notifier.Model
msgByNotification map[reflect.Type]NotificationHandler[any]
tag string
}
func (n *NotifierCmdBar) IsFocussed() bool {
return n.active
}
func (n *NotifierCmdBar) Shortcuts() []statusbar.Shortcut {
return nil
}
func (n *NotifierCmdBar) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
view := n.Notifier.View(ktx, renderer)
// when empty no border style should be applied
if view == "" {
return view
}
// subtract padding (because of the rounded border of the cmdbar) and actual height
ktx.AvailableHeight -= BorderedPadding + lipgloss.Height(view)
return styles.CmdBarWithWidth(ktx.WindowWidth - BorderedPadding).Render(view)
}
func (n *NotifierCmdBar) Update(msg tea.Msg) (bool, tea.Msg, tea.Cmd) {
switch msg := msg.(type) {
case spinner.TickMsg:
cmd := n.Notifier.Update(msg)
return n.active, nil, cmd
case notifier.HideNotificationMsg:
if n.tag == msg.Tag {
cmd := n.Notifier.Update(msg)
return false, nil, cmd
}
return n.active, msg, nil
}
msgType := reflect.TypeOf(msg)
if notification, ok := n.msgByNotification[msgType]; ok {
active, cmd := notification(msg, n.Notifier)
n.active = active
return n.active, nil, cmd
}
return n.active, msg, nil
}
// TODO rename
func WithMsgHandler[T any](bar *NotifierCmdBar, notification NotificationHandler[T]) *NotifierCmdBar {
msgType := reflect.TypeOf((*T)(nil)).Elem()
bar.msgByNotification[msgType] = WrapNotification(notification)
return bar
}
func WrapNotification[T any](n NotificationHandler[T]) NotificationHandler[any] {
return func(msg any, m *notifier.Model) (bool, tea.Cmd) {
typedMsg, ok := msg.(T)
if !ok {
return false, nil
}
return n(typedMsg, m)
}
}
func NewNotifierCmdBar(tag string) *NotifierCmdBar {
return &NotifierCmdBar{
tag: tag,
msgByNotification: make(map[reflect.Type]NotificationHandler[any]),
Notifier: notifier.New(),
}
}
package cmdbar
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/log"
"ktea/kontext"
"ktea/styles"
"ktea/ui"
"ktea/ui/components/statusbar"
)
type state int
const (
hidden state = iota
searching
searched
)
type SearchCmdBar struct {
searchInput *huh.Input
state state
placeholder string
}
func (s *SearchCmdBar) IsFocussed() bool {
return s.state == searching
}
func (s *SearchCmdBar) Shortcuts() []statusbar.Shortcut {
return []statusbar.Shortcut{
{Name: "Confirm", Keybinding: "enter"},
{Name: "Cancel", Keybinding: "esc"},
{Name: "Cancel", Keybinding: "/"},
{Name: "Clear", Keybinding: "C-u"},
}
}
func (s *SearchCmdBar) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
if s.state != hidden {
style := styles.CmdBarWithWidth(ktx.WindowWidth - BorderedPadding)
if s.state == searching {
style = style.BorderForeground(lipgloss.Color(styles.ColorFocusBorder))
} else {
style = style.BorderForeground(lipgloss.Color(styles.ColorBlurBorder))
}
return renderer.RenderWithStyle(s.searchInput.View(), style)
}
return ""
}
func (s *SearchCmdBar) Update(msg tea.Msg) (bool, tea.Msg, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
return s.handleKeyMsg(msg)
}
return s.isActive(), msg, nil
}
func (s *SearchCmdBar) handleKeyMsg(msg tea.KeyMsg) (bool, tea.Msg, tea.Cmd) {
switch msg.String() {
case "/":
s.toggleSearch()
case "enter":
return s.confirmSearch(msg)
case "esc":
s.cancelSearch()
default:
if s.state == searching {
s.updateSearchInput(msg)
}
}
return s.isActive(), msg, nil
}
func (s *SearchCmdBar) toggleSearch() {
if s.state == searching {
log.Debug("Toggling search off")
s.state = hidden
emptyString := ""
s.searchInput.Value(&emptyString)
s.searchInput.Blur()
} else {
log.Debug("Toggling search on")
s.state = searching
s.searchInput.Focus()
}
}
func (s *SearchCmdBar) confirmSearch(msg tea.Msg) (bool, tea.Msg, tea.Cmd) {
if s.state == searched {
return true, msg, nil
}
s.searchInput.Blur()
if s.GetSearchTerm() == "" {
s.state = hidden
} else {
s.state = searched
}
return s.isActive(), nil, nil
}
func (s *SearchCmdBar) cancelSearch() {
if s.state == searching {
s.searchInput.Blur()
s.state = hidden
s.resetSearchInput()
} else if s.state == searched {
} else {
s.state = hidden
}
}
func (s *SearchCmdBar) updateSearchInput(msg tea.Msg) {
input, _ := s.searchInput.Update(msg)
if i, ok := input.(*huh.Input); ok {
s.searchInput = i
}
}
func (s *SearchCmdBar) resetSearchInput() {
s.searchInput = newSearchInput(s.placeholder)
}
func (s *SearchCmdBar) GetSearchTerm() string {
return s.searchInput.GetValue().(string)
}
func (s *SearchCmdBar) IsSearching() bool {
return s.state == searching
}
func (s *SearchCmdBar) isActive() bool {
return s.state == searching || s.state == searched
}
func (s *SearchCmdBar) Reset() {
s.state = hidden
s.searchInput = newSearchInput(s.placeholder)
}
func newSearchInput(placeholder string) *huh.Input {
searchInput := huh.NewInput()
searchInput.Init()
return searchInput
}
func NewSearchCmdBar(placeholder string) *SearchCmdBar {
return &SearchCmdBar{
state: hidden,
searchInput: newSearchInput(placeholder),
placeholder: placeholder,
}
}
package cmdbar
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"ktea/kontext"
"ktea/styles"
"ktea/ui"
"ktea/ui/components/statusbar"
"strings"
)
const (
Asc Direction = true
Desc Direction = false
AscLabel = "▲"
DescLabel = "▼"
)
type SortByCmdBar struct {
sorts []SortLabel
selectedIdx int
activeIdx int
active bool
sortSelectedCallback SortSelectedCallback
}
type Direction bool
type SortByCmdBarOption func(*SortByCmdBar)
type SortLabel struct {
Label string
Direction Direction
}
type SortSelectedCallback func(label SortLabel)
func (d Direction) String() string {
if d == Asc {
return AscLabel
}
return DescLabel
}
func (m *SortByCmdBar) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
builder := strings.Builder{}
for i, sort := range m.sorts {
var (
style lipgloss.Style
bgColor lipgloss.Color
render string
arrow string
)
if sort.Direction == Asc {
arrow = AscLabel
} else {
arrow = DescLabel
}
if m.activeIdx == i {
if m.selectedIdx == i {
bgColor = styles.ColorLightPink
} else {
bgColor = styles.ColorWhite
}
style = lipgloss.NewStyle().
Background(bgColor).
Foreground(lipgloss.Color(styles.ColorBlack))
render = fmt.Sprintf(" %s %s ", sort.Label, arrow)
} else if m.selectedIdx == i {
style = lipgloss.NewStyle().
Background(lipgloss.Color(styles.ColorPink)).
Foreground(lipgloss.Color(styles.ColorBlack))
render = fmt.Sprintf(" %s %s ", sort.Label, arrow)
} else {
style = lipgloss.NewStyle().
Background(lipgloss.Color(styles.ColorDarkGrey)).
Foreground(lipgloss.Color(styles.ColorWhite))
render = fmt.Sprintf(" %s %s ", sort.Label, arrow)
}
builder.WriteString(
style.
Padding(0, 1).
MarginLeft(1).
MarginRight(0).
Render(render),
)
}
return renderer.RenderWithStyle(builder.String(), styles.CmdBarWithWidth(ktx.WindowWidth-BorderedPadding))
}
func (m *SortByCmdBar) Update(msg tea.Msg) (bool, tea.Msg, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "f3":
m.active = !m.active
case "h", "left":
m.prevElem()
case "l", "right":
m.nextElem()
case "esc":
m.active = false
return m.active, nil, nil
case "enter":
if m.activeIdx == m.selectedIdx {
m.sorts[m.selectedIdx].Direction = !m.sorts[m.selectedIdx].Direction
} else {
m.activeIdx = m.selectedIdx
}
if m.sortSelectedCallback != nil {
m.sortSelectedCallback(m.sorts[m.selectedIdx])
}
}
}
return m.active, nil, nil
}
func (m *SortByCmdBar) Shortcuts() []statusbar.Shortcut {
return []statusbar.Shortcut{
{"Move", "←/→"},
{"Select sorting", "enter"},
{"Toggle direction", "enter"},
{"Cancel", "esc/F3"},
}
}
func (m *SortByCmdBar) IsFocussed() bool {
return true
}
func (m *SortByCmdBar) prevElem() {
if m.selectedIdx >= 1 {
m.selectedIdx--
}
}
func (m *SortByCmdBar) nextElem() {
if m.selectedIdx < len(m.sorts)-1 {
m.selectedIdx++
}
}
func (m *SortByCmdBar) SortedBy() SortLabel {
return m.sorts[m.selectedIdx]
}
func (m *SortByCmdBar) PrefixSortIcon(title string) string {
sb := m.SortedBy()
if sb.Label == title {
return lipgloss.NewStyle().
Foreground(lipgloss.Color(styles.ColorPink)).
Bold(true).
Render(sb.Direction.String()) + " " + title
}
return title
}
func WithSortSelectedCallback(callback SortSelectedCallback) SortByCmdBarOption {
return func(bar *SortByCmdBar) {
bar.sortSelectedCallback = callback
}
}
func NewSortByCmdBar(
sorts []SortLabel,
options ...SortByCmdBarOption,
) *SortByCmdBar {
bar := SortByCmdBar{
sorts: sorts,
active: false,
}
for _, option := range options {
option(&bar)
}
return &bar
}
package cmdbar
import (
tea "github.com/charmbracelet/bubbletea"
"ktea/kontext"
"ktea/ui"
"ktea/ui/components/statusbar"
)
type TableCmdsBar[T any] struct {
notifierCBar CmdBar
deleteCBar *DeleteCmdBar[T]
searchCBar *SearchCmdBar
sortByCBar *SortByCmdBar
activeCBar CmdBar
searchPrevActive bool
}
type NotifierConfigurerFunc func(notifier *NotifierCmdBar)
func (m *TableCmdsBar[T]) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
if m.activeCBar != nil {
return m.activeCBar.View(ktx, renderer)
}
return ""
}
func (m *TableCmdsBar[T]) Update(msg tea.Msg, selection *T) (tea.Msg, tea.Cmd) {
// when the notifier is active
if m.activeCBar == m.notifierCBar {
// and has priority (because of a loading spinner) it should handle all msgs
if m.notifierCBar.(*NotifierCmdBar).Notifier.HasPriority() {
active, pmsg, cmd := m.activeCBar.Update(msg)
if !active {
m.activeCBar = nil
}
return pmsg, cmd
}
}
// notifier was not actively spinning
// if it is able to handle the msg it will return nil and the processing can stop
active, pmsg, cmd := m.notifierCBar.Update(msg)
if active && pmsg == nil {
m.activeCBar = m.notifierCBar
return msg, cmd
}
if _, ok := m.activeCBar.(*SearchCmdBar); ok {
m.searchPrevActive = true
}
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "/":
return m.handleSlash(msg)
case "f2":
if selection != nil {
return m.handleF2(selection, msg)
}
return nil, nil
case "f3":
if selection != nil && m.sortByCBar != nil {
return m.handleF3(msg, pmsg, cmd)
}
return pmsg, cmd
}
}
if m.activeCBar != nil {
active, pmsg, cmd := m.activeCBar.Update(msg)
if !active {
if m.searchPrevActive {
m.searchPrevActive = false
m.activeCBar = m.searchCBar
} else {
m.activeCBar = nil
}
}
return pmsg, cmd
}
return msg, nil
}
func (m *TableCmdsBar[T]) handleSlash(msg tea.Msg) (tea.Msg, tea.Cmd) {
active, pmsg, cmd := m.searchCBar.Update(msg)
if active {
m.activeCBar = m.searchCBar
m.deleteCBar.active = false
if m.sortByCBar != nil {
m.sortByCBar.active = false
}
} else {
m.activeCBar = nil
}
return pmsg, cmd
}
func (m *TableCmdsBar[T]) handleF3(msg tea.Msg, pmsg tea.Msg, cmd tea.Cmd) (tea.Msg, tea.Cmd) {
active, pmsg, cmd := m.sortByCBar.Update(msg)
if !active {
m.activeCBar = nil
} else {
m.activeCBar = m.sortByCBar
m.searchCBar.state = hidden
m.deleteCBar.active = false
}
return pmsg, cmd
}
func (m *TableCmdsBar[T]) handleF2(selection *T, msg tea.Msg) (tea.Msg, tea.Cmd) {
active, pmsg, cmd := m.deleteCBar.Update(msg)
if active {
m.activeCBar = m.deleteCBar
m.deleteCBar.Delete(*selection)
m.searchCBar.state = hidden
if m.sortByCBar != nil {
m.sortByCBar.active = false
}
} else {
m.activeCBar = nil
}
return pmsg, cmd
}
func (m *TableCmdsBar[T]) HasSearchedAtLeastOneChar() bool {
return m.searchCBar.IsSearching() && len(m.GetSearchTerm()) > 0
}
func (m *TableCmdsBar[T]) IsFocussed() bool {
return m.activeCBar != nil && m.activeCBar.IsFocussed()
}
func (m *TableCmdsBar[T]) GetSearchTerm() string {
return m.searchCBar.GetSearchTerm()
}
func (m *TableCmdsBar[T]) Shortcuts() []statusbar.Shortcut {
if m.activeCBar == nil {
return nil
}
return m.activeCBar.Shortcuts()
}
func (m *TableCmdsBar[T]) ResetSearch() {
m.searchCBar.Reset()
}
func (m *TableCmdsBar[T]) Hide() {
m.searchCBar.state = hidden
m.deleteCBar.Hide()
m.sortByCBar.active = false
m.activeCBar = nil
}
func NewTableCmdsBar[T any](
deleteCmdBar *DeleteCmdBar[T],
searchCmdBar *SearchCmdBar,
notifierCmdBar *NotifierCmdBar,
sortByCmdBar *SortByCmdBar,
) *TableCmdsBar[T] {
return &TableCmdsBar[T]{
notifierCmdBar,
deleteCmdBar,
searchCmdBar,
sortByCmdBar,
notifierCmdBar,
false,
}
}
package notifier
import (
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
lg "github.com/charmbracelet/lipgloss"
"github.com/muesli/reflow/wordwrap"
"ktea/kontext"
"ktea/styles"
"ktea/ui"
"strings"
"sync/atomic"
"time"
)
type state int
const (
idle state = 0
Err state = 1
success state = 2
Spinning state = 3
)
type Model struct {
spinner spinner.Model
successMsg string
msg string
State state
autoHide atomic.Bool
}
type HideNotificationMsg struct {
Tag string
}
type NotificationHiddenMsg struct{}
func (m *Model) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
if m.State == Spinning {
return renderer.RenderWithStyle(
lg.JoinHorizontal(lg.Top, m.spinner.View(), m.msg),
styles.Notifier.Spinner,
)
} else if m.State == success {
return renderer.RenderWithStyle(
wordwrap.String(m.msg, ktx.WindowWidth),
styles.Notifier.Success,
)
} else if m.State == Err {
return renderer.RenderWithStyle(
wordwrap.String(m.msg, ktx.WindowWidth),
styles.Notifier.Error,
)
}
return renderer.Render("")
}
func (m *Model) Update(msg tea.Msg) tea.Cmd {
switch msg := msg.(type) {
case spinner.TickMsg:
if m.State != Spinning {
return nil
}
s, cmd := m.spinner.Update(msg)
m.spinner = s
return cmd
case HideNotificationMsg:
m.Idle()
return func() tea.Msg {
return NotificationHiddenMsg{}
}
}
return nil
}
func (m *Model) SpinWithLoadingMsg(msg string) tea.Cmd {
m.autoHide.Store(false)
m.State = Spinning
m.msg = "⏳ " + msg
return m.spinner.Tick
}
func (m *Model) SpinWithRocketMsg(msg string) tea.Cmd {
m.autoHide.Store(false)
m.State = Spinning
m.msg = "🚀 " + msg
return m.spinner.Tick
}
func (m *Model) ShowErrorMsg(msg string, error error) tea.Cmd {
m.autoHide.Store(false)
m.State = Err
s := ": "
if msg == "" {
s = ""
}
m.msg = "🚨 " + styles.FG(styles.ColorRed).Render(msg+s) +
styles.FG(styles.ColorWhite).Render(strings.TrimSuffix(error.Error(), "\n"))
return nil
}
func (m *Model) ShowError(error error) tea.Cmd {
m.autoHide.Store(false)
m.State = Err
msg := error.Error()
split := strings.SplitN(msg, ":", 2)
if len(split) > 1 {
m.msg = "🚨 " + styles.FG(styles.ColorRed).Render(split[0]) + ": " +
styles.FG(styles.ColorWhite).Render(strings.TrimSuffix(split[1], "\n"))
} else {
m.msg = "🚨 " + styles.FG(styles.ColorRed).Render(msg)
}
return nil
}
func (m *Model) ShowSuccessMsg(msg string) tea.Cmd {
m.autoHide.Store(false)
m.State = success
m.msg = "🎉 " + msg
return nil
}
func (m *Model) Idle() {
m.autoHide.Store(false)
m.State = idle
m.msg = ""
}
func (m *Model) AutoHideCmd(tag string) tea.Cmd {
m.autoHide.Store(true)
return func() tea.Msg {
time.Sleep(5 * time.Second)
if m.autoHide.Load() {
return HideNotificationMsg{Tag: tag}
}
return nil
}
}
func (m *Model) HasPriority() bool {
return m.State == Spinning
}
func (m *Model) IsIdle() bool {
return m.State == idle
}
func New() *Model {
l := Model{}
l.State = idle
l.spinner = spinner.New()
l.spinner.Spinner = spinner.Dot
return &l
}
package statusbar
import (
"fmt"
lg "github.com/charmbracelet/lipgloss"
"ktea/kontext"
"ktea/styles"
"ktea/ui"
)
type Model struct {
provider Provider
}
type Provider interface {
Shortcuts() []Shortcut
Title() string
}
type UpdateMsg struct {
StatusBar Provider
}
type Shortcut struct {
Name string
Keybinding string
}
func (s *Model) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
var activeCluster string
if ktx.Config.HasClusters() {
activeCluster = styles.Statusbar.
Cluster(ktx.Config.ActiveCluster().Color).
Render(ktx.Config.ActiveCluster().Name)
}
indicator := styles.Statusbar.Indicator.Render(s.provider.Title())
shortcuts := s.provider.Shortcuts()
shortcuts = append([]Shortcut{
{
Name: "Switch Tabs",
Keybinding: "C-←/→/h/l",
},
}, shortcuts...)
rowsPerColumn := 2 // Fixed maximum rows per column
var columns int
if len(shortcuts) <= 4 {
columns = len(shortcuts)
rowsPerColumn = 1
} else {
columns = (len(shortcuts) + rowsPerColumn - 1) / rowsPerColumn
}
// Organize shortcuts into columns
var shortcutsTable [][]Shortcut
for i := 0; i < rowsPerColumn; i++ {
var row []Shortcut
for j := 0; j < columns; j++ {
index := j*rowsPerColumn + i
if index < len(shortcuts) {
row = append(row, shortcuts[index])
}
}
shortcutsTable = append(shortcutsTable, row)
}
// Calculate the maximum width for names and keybindings per column
nameWidths := make([]int, columns)
keyWidths := make([]int, columns)
for _, row := range shortcutsTable {
for col, shortcut := range row {
nameWidth := lg.Width(shortcut.Name)
keyWidth := lg.Width(shortcut.Keybinding)
if nameWidth > nameWidths[col] {
nameWidths[col] = nameWidth
}
if keyWidth > keyWidths[col] {
keyWidths[col] = keyWidth
}
}
}
// Build the shortcuts display
var rows []string
for _, row := range shortcutsTable {
var rowCells []string
for col, shortcut := range row {
paddedName := fmt.Sprintf("%-*s", nameWidths[col], shortcut.Name)
paddedKey := fmt.Sprintf("%-*s", keyWidths[col], shortcut.Keybinding)
shortcutCell := fmt.Sprintf("%s: ≪ %s » ",
styles.Statusbar.BindingName.Render(paddedName),
styles.Statusbar.Key.Render(paddedKey),
)
rowCells = append(rowCells, shortcutCell)
}
rows = append(rows, styles.Statusbar.Text.Render(lg.JoinHorizontal(lg.Left, rowCells...)))
}
shortCuts := lg.JoinVertical(lg.Top, rows...)
leftover := ktx.WindowWidth - (lg.Width(activeCluster)) - (lg.Width(indicator))
barView := lg.NewStyle().MarginTop(1).Render(lg.JoinHorizontal(lg.Top,
activeCluster,
indicator,
styles.Statusbar.Spacer.Width(leftover).Render(""),
))
return renderer.Render(lg.JoinVertical(lg.Top, styles.Statusbar.Shortcuts.Render(shortCuts), barView))
}
func New(provider Provider) *Model {
return &Model{provider}
}
package tab
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"ktea/kontext"
"ktea/styles"
"ktea/ui"
"strings"
)
type Label string
type Tab struct {
Title string
Label
}
type Model struct {
tabs []Tab
// zero indexed
activeTab int
}
func (m *Model) View(ctx *kontext.ProgramKtx, renderer *ui.Renderer) string {
if len(m.tabs) == 0 {
return ""
}
tabsToRender := make([]string, len(m.tabs))
for i, t := range m.tabs {
var tab string
if i == m.activeTab {
tab = styles.Tab.ActiveTab.Render(t.Title)
} else {
tab = styles.Tab.Tab.Render(t.Title)
}
tabsToRender = append(tabsToRender, tab)
}
renderedTabs := lipgloss.JoinHorizontal(lipgloss.Top, tabsToRender...)
tabLine := strings.Builder{}
leftOverSpace := ctx.WindowWidth - lipgloss.Width(renderedTabs)
for i := 0; i < leftOverSpace; i++ {
tabLine.WriteString("─")
}
s := renderedTabs + tabLine.String()
return renderer.Render(s)
}
func (m *Model) Update(msg tea.Msg) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyCtrlLeft, tea.KeyCtrlH:
m.Prev()
case tea.KeyCtrlRight, tea.KeyCtrlL:
m.Next()
}
}
}
func (m *Model) Next() {
if m.activeTab < m.numberOfTabs()-1 {
m.activeTab++
}
}
func (m *Model) Prev() {
if m.activeTab > 0 {
m.activeTab--
}
}
func (m *Model) numberOfTabs() int {
return len(m.tabs)
}
func (m *Model) GoToTab(label Label) {
for i, t := range m.tabs {
if t.Label == label {
m.activeTab = i
}
}
}
func (m *Model) ActiveTab() Tab {
if m.tabs == nil {
return Tab{}
}
return m.tabs[m.activeTab]
}
func New(tabs ...Tab) Model {
return Model{
tabs: tabs,
}
}
package table
import (
"github.com/charmbracelet/bubbles/table"
"ktea/styles"
)
func NewDefaultTable() table.Model {
return table.New(
table.WithFocused(true),
table.WithStyles(styles.Table.Styles),
)
}
package ui
import (
"bytes"
"encoding/json"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters"
"github.com/alecthomas/chroma/v2/lexers"
chrome_styles "github.com/alecthomas/chroma/v2/styles"
"strings"
)
func PrettyPrintJson(text string) string {
builder := &strings.Builder{}
formatter := formatters.TTY256
style := chrome_styles.Get("github-dark")
lexer := lexers.Fallback
var prettyJSON bytes.Buffer
var iterator chroma.Iterator
if strings.Contains(text, "{") &&
strings.Contains(text, "}") {
json.Indent(&prettyJSON, []byte(text), "", "\t")
lexer = lexers.Get("json")
iterator, _ = lexer.Tokenise(nil, prettyJSON.String())
} else if strings.Contains(text, "/>") {
lexer = lexers.Get("XML")
iterator, _ = lexer.Tokenise(nil, text)
} else {
lexer = lexers.Fallback
iterator, _ = lexer.Tokenise(nil, text)
}
formatter.Format(builder, style, iterator)
return builder.String()
}
package ui
import "github.com/charmbracelet/lipgloss"
func JoinVertical(position lipgloss.Position, views ...string) string {
return join(views, position)
}
func JoinHorizontal(views ...string) string {
return join(views, lipgloss.Center)
}
func join(views []string, position lipgloss.Position) string {
var renderViews []string
for _, view := range views {
if view != "" {
renderViews = append(renderViews, view)
}
}
return lipgloss.JoinVertical(position, renderViews...)
}
package cgroups_page
import (
"fmt"
"github.com/charmbracelet/log"
"ktea/kadmin"
"ktea/kontext"
"ktea/styles"
"ktea/ui"
"ktea/ui/components/cmdbar"
"ktea/ui/components/notifier"
"ktea/ui/components/statusbar"
ktable "ktea/ui/components/table"
"ktea/ui/pages/nav"
"reflect"
"slices"
"sort"
"strconv"
"strings"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type state int
const (
stateRefreshing state = iota
stateLoading
stateLoaded
)
type Model struct {
lister kadmin.CGroupLister
table table.Model
tcb *cmdbar.TableCmdsBar[string]
groups []*kadmin.ConsumerGroup
rows []table.Row
tableFocussed bool
sort cmdbar.SortLabel
state state
goToTop bool
}
func (m *Model) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
var views []string
cmdBarView := m.tcb.View(ktx, renderer)
views = append(views, cmdBarView)
m.table.SetWidth(ktx.WindowWidth - 2)
m.table.SetColumns([]table.Column{
{m.columnTitle("Consumer Group"), int(float64(ktx.WindowWidth-5) * 0.7)},
{m.columnTitle("Members"), int(float64(ktx.WindowWidth-5) * 0.3)},
})
m.table.SetRows(m.rows)
m.table.SetHeight(ktx.AvailableHeight - 2)
if m.table.SelectedRow() == nil && len(m.table.Rows()) > 0 {
m.goToTop = true
}
if m.goToTop {
m.table.GotoTop()
m.goToTop = false
}
var tableView string
styledTable := renderer.RenderWithStyle(m.table.View(), styles.Table.Blur)
embeddedText := map[styles.BorderPosition]styles.EmbeddedTextFunc{
styles.TopMiddleBorder: styles.EmbeddedBorderText("Total Consumer Groups", fmt.Sprintf(" %d/%d", len(m.rows), len(m.groups))),
styles.BottomMiddleBorder: styles.EmbeddedBorderText("Total Consumer Groups", fmt.Sprintf(" %d/%d", len(m.rows), len(m.groups))),
}
tableView = styles.Borderize(styledTable, m.tableFocussed, embeddedText)
return ui.JoinVertical(lipgloss.Top, cmdBarView, tableView)
}
func (m *Model) Update(msg tea.Msg) tea.Cmd {
log.Debug("Received Update", "msg", reflect.TypeOf(msg))
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "enter":
// only accept enter when the table is focussed
if !m.tcb.IsFocussed() {
// TODO ignore enter when there are no groups loaded
return ui.PublishMsg(nav.LoadCGroupTopicsPageMsg{GroupName: *m.SelectedCGroup()})
}
case "f5":
m.groups = nil
m.state = stateRefreshing
return m.lister.ListCGroups
}
case kadmin.CGroupDeletionStartedMsg:
cmds = append(cmds, msg.AwaitCompletion)
case kadmin.ConsumerGroupsListedMsg:
m.state = stateLoaded
m.groups = msg.ConsumerGroups
m.tcb.ResetSearch()
case kadmin.CGroupDeletedMsg:
for i, group := range m.groups {
if group.Name == msg.GroupName {
m.groups = slices.Delete(m.groups, i, i+1)
break
}
}
}
var cmd tea.Cmd
msg, cmd = m.tcb.Update(msg, m.SelectedCGroup())
m.tableFocussed = !m.tcb.IsFocussed()
cmds = append(cmds, cmd)
m.rows = m.createRows()
// make sure table navigation is off when the cmdbar is focussed
if !m.tcb.IsFocussed() {
t, cmd := m.table.Update(msg)
m.table = t
cmds = append(cmds, cmd)
}
if m.tcb.HasSearchedAtLeastOneChar() {
m.goToTop = true
}
return tea.Batch(cmds...)
}
func (m *Model) columnTitle(title string) string {
if m.sort.Label == title {
return lipgloss.NewStyle().
Foreground(lipgloss.Color(styles.ColorPink)).
Bold(true).
Render(m.sort.Direction.String()) + " " + title
}
return title
}
func (m *Model) createRows() []table.Row {
var rows []table.Row
for _, group := range m.groups {
if m.tcb.GetSearchTerm() != "" {
if strings.Contains(strings.ToLower(group.Name), strings.ToLower(m.tcb.GetSearchTerm())) {
rows = m.appendGroupToRows(rows, group)
}
} else {
rows = m.appendGroupToRows(rows, group)
}
}
sort.SliceStable(rows, func(i, j int) bool {
switch m.sort.Label {
case "Consumer Group":
if m.sort.Direction == cmdbar.Asc {
return rows[i][0] < rows[j][0]
}
return rows[i][0] > rows[j][0]
case "Members":
partitionI, _ := strconv.Atoi(rows[i][1])
partitionJ, _ := strconv.Atoi(rows[j][1])
if m.sort.Direction == cmdbar.Asc {
return partitionI < partitionJ
}
return partitionI > partitionJ
default:
panic(fmt.Sprintf("unexpected sort label: %s", m.sort.Label))
}
})
return rows
}
func (m *Model) appendGroupToRows(rows []table.Row, group *kadmin.ConsumerGroup) []table.Row {
rows = append(
rows,
table.Row{
group.Name,
strconv.Itoa(len(group.Members)),
},
)
return rows
}
func (m *Model) SelectedCGroup() *string {
selectedRow := m.table.SelectedRow()
var selectedTopic string
if selectedRow != nil {
selectedTopic = selectedRow[0]
}
return &selectedTopic
}
func (m *Model) Shortcuts() []statusbar.Shortcut {
if m.tcb.IsFocussed() {
shortCuts := m.tcb.Shortcuts()
if shortCuts != nil {
return shortCuts
}
}
return []statusbar.Shortcut{
{"Search", "/"},
{"View", "enter"},
{"Delete", "F2"},
{"Sort", "F3"},
{"Refresh", "F5"},
}
}
func (m *Model) Title() string {
return "Consumer Groups"
}
func New(
lister kadmin.CGroupLister,
deleter kadmin.CGroupDeleter,
) (*Model, tea.Cmd) {
m := &Model{}
m.lister = lister
// Use ktable.NewDefaultTable() instead of direct initialization
t := ktable.NewDefaultTable()
m.table = t
deleteMsgFunc := func(topic string) string {
message := topic + lipgloss.NewStyle().
Foreground(lipgloss.Color(styles.ColorIndigo)).
Bold(true).
Render(" will be deleted permanently")
return message
}
deleteFunc := func(group string) tea.Cmd {
return func() tea.Msg {
return deleter.DeleteCGroup(group)
}
}
notifierCmdBar := cmdbar.NewNotifierCmdBar("cgroups-page")
cmdbar.WithMsgHandler(
notifierCmdBar,
func(
msg kadmin.ConsumerGroupListingStartedMsg,
m *notifier.Model,
) (bool, tea.Cmd) {
cmd := m.SpinWithLoadingMsg("Loading Consumer Groups")
return true, cmd
},
)
cmdbar.WithMsgHandler(
notifierCmdBar,
func(
msg ui.RegainedFocusMsg,
model *notifier.Model,
) (bool, tea.Cmd) {
if m.state == stateRefreshing || m.state == stateLoading {
cmd := model.SpinWithLoadingMsg("Loading Consumer Groups")
return true, cmd
}
return false, nil
},
)
cmdbar.WithMsgHandler(
notifierCmdBar,
func(
msg kadmin.ConsumerGroupsListedMsg,
m *notifier.Model,
) (bool, tea.Cmd) {
m.Idle()
return false, nil
},
)
cmdbar.WithMsgHandler(
notifierCmdBar,
func(
msg kadmin.CGroupDeletionStartedMsg,
m *notifier.Model,
) (bool, tea.Cmd) {
cmd := m.SpinWithLoadingMsg("Deleting Consumer Group")
return true, cmd
},
)
cmdbar.WithMsgHandler(
notifierCmdBar,
func(
msg kadmin.CGroupDeletedMsg,
m *notifier.Model,
) (bool, tea.Cmd) {
m.Idle()
return false, nil
},
)
cmdbar.WithMsgHandler(
notifierCmdBar,
func(
msg kadmin.CGroupDeletionErrMsg,
m *notifier.Model,
) (bool, tea.Cmd) {
cmd := m.ShowErrorMsg("Failed to delete group", msg.Err)
return true, cmd
},
)
sortByBar := cmdbar.NewSortByCmdBar(
[]cmdbar.SortLabel{
{
Label: "Consumer Group",
Direction: cmdbar.Asc,
},
{
Label: "Members",
Direction: cmdbar.Desc,
},
},
cmdbar.WithSortSelectedCallback(func(label cmdbar.SortLabel) {
m.sort = label
}),
)
m.tcb = cmdbar.NewTableCmdsBar[string](
cmdbar.NewDeleteCmdBar(deleteMsgFunc, deleteFunc),
cmdbar.NewSearchCmdBar("Search Consumer Group"),
notifierCmdBar,
sortByBar,
)
m.sort = sortByBar.SortedBy()
m.state = stateLoading
return m, m.lister.ListCGroups
}
package cgroups_page
import (
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"ktea/kadmin"
"ktea/kontext"
"ktea/ui"
"ktea/ui/components/notifier"
)
type CmdBar struct {
notifier *notifier.Model
}
func (c *CmdBar) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
return c.notifier.View(ktx, renderer)
}
func (c *CmdBar) Update(msg tea.Msg) tea.Cmd {
var cmd tea.Cmd
switch msg.(type) {
case spinner.TickMsg:
cmd = c.notifier.Update(msg)
case kadmin.ConsumerGroupListingStartedMsg:
cmd = c.notifier.SpinWithLoadingMsg("Loading Consumer Groups")
case kadmin.ConsumerGroupsListedMsg:
c.notifier.Idle()
}
return cmd
}
func NewCmdBar() *CmdBar {
return &CmdBar{
notifier: notifier.New(),
}
}
package cgroups_topics_page
import (
tea "github.com/charmbracelet/bubbletea"
"ktea/kontext"
"ktea/ui"
"ktea/ui/components/cmdbar"
"ktea/ui/components/statusbar"
)
type CGroupCmdbar[T any] struct {
searchWidget cmdbar.CmdBar
notifierWidget cmdbar.CmdBar
active cmdbar.CmdBar
searchPrevActive bool
}
type NotifierConfigurerFunc func(notifier *cmdbar.NotifierCmdBar)
func (m *CGroupCmdbar[T]) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
if m.active != nil {
return m.active.View(ktx, renderer)
}
return ""
}
func (m *CGroupCmdbar[T]) Update(msg tea.Msg) (tea.Msg, tea.Cmd) {
// when the notifier is active and has priority (because of a loading spinner) it should handle all msgs
if m.active == m.notifierWidget {
if m.notifierWidget.(*cmdbar.NotifierCmdBar).Notifier.HasPriority() {
active, pmsg, cmd := m.active.Update(msg)
if !active {
m.active = nil
}
return pmsg, cmd
}
}
// notifier was not actively spinning
// if it is able to handle the msg it will return nil and the processing can stop
active, pmsg, cmd := m.notifierWidget.Update(msg)
if active && pmsg == nil {
m.active = m.notifierWidget
return msg, cmd
}
if _, ok := m.active.(*cmdbar.SearchCmdBar); ok {
m.searchPrevActive = true
}
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "/":
active, pmsg, cmd := m.searchWidget.Update(msg)
if active {
m.active = m.searchWidget
} else {
m.active = nil
}
return pmsg, cmd
}
}
if m.active != nil {
active, pmsg, cmd := m.active.Update(msg)
if !active {
if m.searchPrevActive {
m.searchPrevActive = false
m.active = m.searchWidget
} else {
m.active = nil
}
}
return pmsg, cmd
}
return msg, nil
}
func (m *CGroupCmdbar[T]) HasSearchedAtLeastOneChar() bool {
return m.searchWidget.(*cmdbar.SearchCmdBar).IsSearching() && len(m.GetSearchTerm()) > 0
}
func (m *CGroupCmdbar[T]) IsFocussed() bool {
return m.active != nil && m.active.IsFocussed()
}
func (m *CGroupCmdbar[T]) GetSearchTerm() string {
if searchBar, ok := m.searchWidget.(*cmdbar.SearchCmdBar); ok {
return searchBar.GetSearchTerm()
}
return ""
}
func (m *CGroupCmdbar[T]) Shortcuts() []statusbar.Shortcut {
if m.active == nil {
return nil
}
return m.active.Shortcuts()
}
func NewCGroupCmdbar[T any](
searchCmdBar *cmdbar.SearchCmdBar,
notifierCmdBar *cmdbar.NotifierCmdBar,
) *CGroupCmdbar[T] {
return &CGroupCmdbar[T]{
searchCmdBar,
notifierCmdBar,
notifierCmdBar,
false,
}
}
package cgroups_topics_page
import (
"fmt"
"github.com/charmbracelet/log"
"ktea/kadmin"
"ktea/kontext"
"ktea/styles"
"ktea/ui"
"ktea/ui/components/cmdbar"
"ktea/ui/components/notifier"
"ktea/ui/components/statusbar"
"ktea/ui/pages/nav"
"reflect"
"slices"
"sort"
"strconv"
"strings"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
lg "github.com/charmbracelet/lipgloss"
"github.com/dustin/go-humanize"
)
type tableFocus int
type state int
const (
topicFocus tableFocus = 0
offsetFocus tableFocus = 1
stateNoOffsets state = 0
stateOffsetsLoading state = 1
stateOffsetsLoaded state = 2
)
type Model struct {
tableFocus tableFocus
topicsTable table.Model
offsetsTable table.Model
topicsRows []table.Row
offsetRows []table.Row
groupName string
topicByPartOffset map[string][]partOffset
cmdBar *CGroupCmdbar[string]
offsets []kadmin.TopicPartitionOffset
state state
}
func (m *Model) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
if m.state == stateNoOffsets {
return styles.
CenterText(ktx.WindowWidth, ktx.AvailableHeight).
Render("👀 No Committed Offsets Found")
}
cmdBarView := m.cmdBar.View(ktx, renderer)
halfWidth := int(float64(ktx.WindowWidth / 2))
m.topicsTable.SetHeight(ktx.AvailableHeight - 4)
m.topicsTable.SetWidth(halfWidth - 2)
m.topicsTable.SetColumns([]table.Column{
{"Topic Name", int(float64(halfWidth - 4))},
})
m.topicsTable.SetRows(m.topicsRows)
m.offsetsTable.SetHeight(ktx.AvailableHeight - 4)
m.offsetsTable.SetColumns([]table.Column{
{"Partition", int(float64(halfWidth-6) * 0.5)},
{"Offset", int(float64(halfWidth-5) * 0.5)},
})
m.offsetsTable.SetRows(m.offsetRows)
topicTableStyle := styles.Table.Blur
offsetTableStyle := styles.Table.Blur
if m.tableFocus == topicFocus {
topicTableStyle = styles.Table.Focus
offsetTableStyle = styles.Table.Blur
}
topicsTableView := renderer.RenderWithStyle(m.topicsTable.View(), topicTableStyle)
embeddedText := map[styles.BorderPosition]styles.EmbeddedTextFunc{
styles.TopMiddleBorder: func(active bool) string {
return lg.NewStyle().
Foreground(lg.Color(styles.ColorPink)).
Bold(true).
Render(fmt.Sprintf("Total Topics: %d", len(m.topicsRows)))
},
}
topicsTableBorderedView := styles.Borderize(topicsTableView, m.tableFocus == topicFocus, embeddedText)
offsetsTableView := renderer.RenderWithStyle(m.offsetsTable.View(), offsetTableStyle)
offsetsTableEmbeddedText := map[styles.BorderPosition]styles.EmbeddedTextFunc{
styles.TopMiddleBorder: func(active bool) string {
return lg.NewStyle().
Foreground(lg.Color(styles.ColorPink)).
Bold(true).
Render(fmt.Sprintf("Total Partitions: %d", len(m.offsetRows)))
},
}
offsetsTableBorderedView := styles.Borderize(offsetsTableView, m.tableFocus == offsetFocus, offsetsTableEmbeddedText)
return ui.JoinVertical(lg.Left,
cmdBarView,
lg.JoinHorizontal(
lg.Top,
[]string{
topicsTableBorderedView,
offsetsTableBorderedView,
}...,
),
)
}
type partOffset struct {
partition string
offset int64
}
func (m *Model) Update(msg tea.Msg) tea.Cmd {
log.Debug("Received Update", "msg", reflect.TypeOf(msg))
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "esc":
// only accept when the table is focussed
if !m.cmdBar.IsFocussed() {
return ui.PublishMsg(nav.LoadCGroupsPageMsg{})
}
}
case kadmin.OffsetListingStartedMsg:
cmds = append(cmds, msg.AwaitCompletion)
case kadmin.OffsetListedMsg:
if msg.Offsets == nil {
m.state = stateNoOffsets
} else {
m.state = stateOffsetsLoaded
m.offsets = msg.Offsets
}
}
var cmd tea.Cmd
msg, cmd = m.cmdBar.Update(msg)
if cmd != nil {
cmds = append(cmds, cmd)
}
// make sure table navigation is off when the cmdbar is focussed
if !m.cmdBar.IsFocussed() {
if m.tableFocus == topicFocus {
m.topicsTable, cmd = m.topicsTable.Update(msg)
} else {
m.offsetsTable, cmd = m.offsetsTable.Update(msg)
}
if cmd != nil {
cmds = append(cmds, cmd)
}
}
// recreate offset rows after topic table has been updated
m.recreateTopicRows()
m.recreateOffsetRows()
return tea.Batch(cmds...)
}
func (m *Model) recreateOffsetRows() {
// if topics aren't listed yet
if m.topicsRows == nil {
return
}
selectedTopic := m.selectedRow()
if selectedTopic != "" {
m.offsetRows = []table.Row{}
for _, partOffset := range m.topicByPartOffset[selectedTopic] {
m.offsetRows = append(m.offsetRows, table.Row{
partOffset.partition,
humanize.Comma(partOffset.offset),
})
}
sort.SliceStable(m.offsetRows, func(i, j int) bool {
a, _ := strconv.Atoi(m.offsetRows[i][0])
b, _ := strconv.Atoi(m.offsetRows[j][0])
return a < b
})
}
}
func (m *Model) recreateTopicRows() {
if m.offsets == nil || len(m.offsets) == 0 {
return
}
var topics []string
m.topicByPartOffset = make(map[string][]partOffset)
for _, offset := range m.offsets {
if m.cmdBar.GetSearchTerm() != "" {
if !strings.Contains(offset.Topic, m.cmdBar.GetSearchTerm()) {
continue
}
}
if !slices.Contains(topics, offset.Topic) {
topics = append(topics, offset.Topic)
}
partOffset := partOffset{
partition: strconv.FormatInt(int64(offset.Partition), 10),
offset: offset.Offset,
}
m.topicByPartOffset[offset.Topic] = append(m.topicByPartOffset[offset.Topic], partOffset)
}
m.topicsRows = []table.Row{}
for _, topic := range topics {
m.topicsRows = append(m.topicsRows, table.Row{topic})
}
sort.SliceStable(m.topicsRows, func(i, j int) bool {
return m.topicsRows[i][0] < m.topicsRows[j][0]
})
}
func (m *Model) selectedRow() string {
row := m.topicsTable.SelectedRow()
if row == nil {
return m.topicsRows[0][0]
}
return row[0]
}
func (m *Model) Shortcuts() []statusbar.Shortcut {
return []statusbar.Shortcut{
{"Go Back", "esc"},
{"Search", "/"},
{"Refresh", "F5"},
}
}
func (m *Model) Title() string {
return "Consumer Groups / " + m.groupName
}
func New(lister kadmin.OffsetLister, group string) (*Model, tea.Cmd) {
tt := table.New(
table.WithFocused(true),
table.WithStyles(styles.Table.Styles),
)
ot := table.New(
table.WithFocused(true),
table.WithStyles(styles.Table.Styles),
)
notifierCmdBar := cmdbar.NewNotifierCmdBar("cgroup")
cmdbar.WithMsgHandler(
notifierCmdBar,
func(
msg kadmin.OffsetListingStartedMsg,
m *notifier.Model,
) (bool, tea.Cmd) {
cmd := m.SpinWithLoadingMsg("Loading Offsets")
return true, cmd
},
)
cmdbar.WithMsgHandler(
notifierCmdBar,
func(
msg kadmin.OffsetListedMsg,
m *notifier.Model,
) (bool, tea.Cmd) {
m.Idle()
return false, m.AutoHideCmd("cgroup")
},
)
return &Model{
cmdBar: NewCGroupCmdbar[string](
cmdbar.NewSearchCmdBar("Search groups by name"),
notifierCmdBar,
),
tableFocus: topicFocus,
groupName: group,
topicsTable: tt,
offsetsTable: ot,
state: stateOffsetsLoading,
}, func() tea.Msg {
return lister.ListOffsets(group)
}
}
package clusters_page
import (
"fmt"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/log"
"ktea/config"
"ktea/kadmin"
"ktea/kontext"
"ktea/styles"
"ktea/ui"
"ktea/ui/components/cmdbar"
"ktea/ui/components/notifier"
"ktea/ui/components/statusbar"
ktable "ktea/ui/components/table"
"ktea/ui/pages/nav"
"reflect"
"sort"
"strings"
)
type Model struct {
table *table.Model
rows []table.Row
ktx *kontext.ProgramKtx
cmdBar *cmdbar.TableCmdsBar[string]
tableFocussed bool
connChecker kadmin.ConnChecker
}
type ClusterSwitchedMsg struct {
Cluster *config.Cluster
}
func (m *Model) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
cmdBarView := m.cmdBar.View(ktx, renderer)
m.table.SetColumns([]table.Column{
{"Active", int(float64(ktx.WindowWidth-5) * 0.05)},
{"Name", int(float64(ktx.WindowWidth-5) * 0.95)},
})
m.table.SetHeight(ktx.AvailableHeight - 2)
m.table.SetWidth(ktx.WindowWidth - 2)
m.table.SetRows(m.rows)
embeddedText := map[styles.BorderPosition]styles.EmbeddedTextFunc{
styles.TopMiddleBorder: styles.EmbeddedBorderText("Total Clusters", fmt.Sprintf(" %d/%d", len(m.rows), len(m.ktx.Config.Clusters))),
styles.BottomMiddleBorder: styles.EmbeddedBorderText("Total Clusters", fmt.Sprintf(" %d/%d", len(m.rows), len(m.ktx.Config.Clusters))),
}
borderedView := styles.Borderize(m.table.View(), m.tableFocussed, embeddedText)
return ui.JoinVertical(lipgloss.Top, cmdBarView, borderedView)
}
func (m *Model) Update(msg tea.Msg) tea.Cmd {
log.Debug(reflect.TypeOf(msg))
var cmds []tea.Cmd
switch msg := msg.(type) {
case ClusterSwitchedMsg:
// immediately recreate the rows updating the active cluster
m.rows = m.createRows()
case kadmin.ConnCheckStartedMsg:
cmds = append(cmds, msg.AwaitCompletion)
case kadmin.ConnCheckSucceededMsg:
cmds = append(cmds, func() tea.Msg {
kadmin.MaybeIntroduceLatency()
activeCluster := m.ktx.Config.SwitchCluster(*m.SelectedCluster())
m.rows = m.createRows()
return ClusterSwitchedMsg{activeCluster}
})
case tea.KeyMsg:
switch msg.String() {
case "enter":
if !m.cmdBar.IsFocussed() {
cmds = append(cmds, func() tea.Msg {
cluster := m.ktx.Config.FindClusterByName(*m.SelectedCluster())
return m.connChecker(cluster)
})
}
}
}
msg, cmd := m.cmdBar.Update(msg, m.SelectedCluster())
m.tableFocussed = !m.cmdBar.IsFocussed()
cmds = append(cmds, cmd)
// make sure table navigation is off when the cmdbar is focussed
if !m.cmdBar.IsFocussed() {
t, cmd := m.table.Update(msg)
m.table = &t
cmds = append(cmds, cmd)
}
m.rows = m.createRows()
return tea.Batch(cmds...)
}
func (m *Model) Shortcuts() []statusbar.Shortcut {
return []statusbar.Shortcut{
{"Switch Cluster", "enter"},
{"Edit", "C-e"},
{"Delete", "F2"},
{"Create", "C-n"},
}
}
func (m *Model) Title() string {
return "Clusters"
}
func (m *Model) SelectedCluster() *string {
row := m.table.SelectedRow()
if row == nil {
return nil
}
return &row[1]
}
func (m *Model) createRows() []table.Row {
var rows []table.Row
for _, c := range m.ktx.Config.Clusters {
if m.cmdBar.GetSearchTerm() != "" {
if !strings.Contains(strings.ToUpper(c.Name), strings.ToUpper(m.cmdBar.GetSearchTerm())) {
continue
}
}
var activeCell string
if c.Active {
activeCell = "X"
} else {
activeCell = ""
}
rows = append(rows, table.Row{activeCell, c.Name})
}
sort.SliceStable(rows, func(i, j int) bool {
return rows[i][1] < rows[j][1]
})
return rows
}
type ActiveClusterDeleteErrMsg struct {
}
func New(
ktx *kontext.ProgramKtx,
connChecker kadmin.ConnChecker,
) (nav.Page, tea.Cmd) {
model := Model{}
model.connChecker = connChecker
model.tableFocussed = true
deleteFunc := func(subject string) tea.Cmd {
return func() tea.Msg {
selectedCluster := *model.SelectedCluster()
model.ktx.Config.DeleteCluster(selectedCluster)
return config.ClusterDeletedMsg{Name: selectedCluster}
}
}
deleteMsgFunc := func(subject string) string {
message := subject + lipgloss.NewStyle().
Foreground(lipgloss.Color(styles.ColorIndigo)).
Bold(true).
Render(" will be deleted permanently")
return message
}
validateFunc := func(clusterName string) (bool, tea.Cmd) {
if ktx.Config.ActiveCluster().Name == clusterName {
return false, func() tea.Msg {
return ActiveClusterDeleteErrMsg{}
}
}
return true, nil
}
searchCmdBar := cmdbar.NewSearchCmdBar("Search clusters by name")
deleteCmdBar := cmdbar.NewDeleteCmdBar(deleteMsgFunc, deleteFunc, cmdbar.WithValidateFn(validateFunc))
notifierCmdBar := cmdbar.NewNotifierCmdBar("clusters-page")
clusterDeletedHandler := func(msg config.ClusterDeletedMsg, m *notifier.Model) (bool, tea.Cmd) {
m.ShowSuccessMsg("Cluster has been deleted")
return true, m.AutoHideCmd("clusters-page")
}
activeClusterDeleteErrMsgHandler := func(msg ActiveClusterDeleteErrMsg, m *notifier.Model) (bool, tea.Cmd) {
m.ShowErrorMsg("Unable to delete", fmt.Errorf("active cluster"))
return true, m.AutoHideCmd("clusters-page")
}
connCheckStartedHandler := func(msg kadmin.ConnCheckStartedMsg, m *notifier.Model) (bool, tea.Cmd) {
cmd := m.SpinWithLoadingMsg("Checking connectivity to " + msg.Cluster.Name)
return true, cmd
}
connCheckErrHandler := func(msg kadmin.ConnCheckErrMsg, m *notifier.Model) (bool, tea.Cmd) {
m.ShowErrorMsg("Connection check failed", msg.Err)
return true, nil
}
connErrHandler := func(msg kadmin.ConnErrMsg, m *notifier.Model) (bool, tea.Cmd) {
m.ShowErrorMsg("Unable to connect, try again or edit clusters", msg.Err)
return true, nil
}
connCheckSucceededHandler := func(msg kadmin.ConnCheckSucceededMsg, m *notifier.Model) (bool, tea.Cmd) {
cmd := m.SpinWithRocketMsg("Connection check succeeded, switching cluster")
return true, cmd
}
clusterSwitchedHandler := func(msg ClusterSwitchedMsg, m *notifier.Model) (bool, tea.Cmd) {
m.ShowSuccessMsg("Cluster switched to " + msg.Cluster.Name)
return true, m.AutoHideCmd("clusters-page")
}
cmdbar.WithMsgHandler(notifierCmdBar, clusterDeletedHandler)
cmdbar.WithMsgHandler(notifierCmdBar, activeClusterDeleteErrMsgHandler)
cmdbar.WithMsgHandler(notifierCmdBar, connCheckStartedHandler)
cmdbar.WithMsgHandler(notifierCmdBar, connCheckErrHandler)
cmdbar.WithMsgHandler(notifierCmdBar, connErrHandler)
cmdbar.WithMsgHandler(notifierCmdBar, connCheckSucceededHandler)
cmdbar.WithMsgHandler(notifierCmdBar, clusterSwitchedHandler)
model.ktx = ktx
t := ktable.NewDefaultTable()
model.table = &t
model.cmdBar = cmdbar.NewTableCmdsBar(
deleteCmdBar,
searchCmdBar,
notifierCmdBar,
nil,
)
model.rows = model.createRows()
return &model, nil
}
package configs_page
import (
"fmt"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"ktea/kadmin"
"ktea/kontext"
"ktea/styles"
"ktea/ui"
"ktea/ui/components/notifier"
"ktea/ui/pages/nav"
)
type state int
const (
HIDDEN state = 0
SEARCHING state = 1
SEARCHED state = 2
EDITING state = 3
UPDATING state = 4
UPDATE_FAILED state = 5
UPDATE_SUCCEEDED state = 6
LOADING state = 7
)
type CmdBarModel struct {
state state
searchInput *huh.Input
editInput *huh.Input
notifier *notifier.Model
configUpdater kadmin.ConfigUpdater
topicConfigLister kadmin.TopicConfigLister
topic string
updated bool
}
type SelectedTopicConfig struct {
Topic string
ConfigKey string
ConfigValue string
}
type HideBarMsg struct{}
func (m *CmdBarModel) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
var views []string
if m.state == SEARCHING || m.state == SEARCHED {
views = append(views, renderer.RenderWithStyle(m.searchInput.View(), styles.CmdBar.Width(ktx.WindowWidth-2)))
} else if m.state == EDITING {
views = append(views, renderer.RenderWithStyle(m.editInput.View(), styles.CmdBar.Width(ktx.WindowWidth-2)))
} else if m.state == UPDATING || m.state == UPDATE_FAILED || m.state == UPDATE_SUCCEEDED || m.state == LOADING {
views = append(views, renderer.RenderWithStyle(m.notifier.View(ktx, renderer), styles.CmdBar.Width(ktx.WindowWidth-2)))
}
return ui.JoinVertical(lipgloss.Top, views...)
}
// Update returns the tea.Msg if it is not being handled or nil if it is
func (m *CmdBarModel) Update(msg tea.Msg, stc SelectedTopicConfig) (tea.Msg, tea.Cmd) {
switch msg := msg.(type) {
case spinner.TickMsg:
return nil, m.notifier.Update(msg)
case tea.KeyMsg:
if msg.String() == "/" {
m.state = SEARCHING
m.searchInput.Focus()
return nil, nil
} else if msg.String() == "esc" {
if m.state == SEARCHING {
m.searchInput = newSearchInput()
}
if m.IsFocused() {
m.state = HIDDEN
} else {
return nil, ui.PublishMsg(nav.LoadTopicsPageMsg{})
}
return nil, nil
} else if msg.String() == "e" && isEditable(m) {
m.state = EDITING
m.editInput = newEditInput(stc.ConfigValue)
m.editInput.Focus()
return nil, nil
} else if msg.String() == "enter" {
if m.state == SEARCHING {
if m.GetSearchTerm() == "" {
m.state = HIDDEN
} else {
m.state = SEARCHED
}
} else if m.state == EDITING {
m.state = UPDATING
return nil, tea.Batch(
m.notifier.SpinWithRocketMsg("Updating Topic Config"),
func() tea.Msg {
return m.configUpdater.UpdateConfig(kadmin.TopicConfigToUpdate{
Topic: stc.Topic,
Key: stc.ConfigKey,
Value: m.editInput.GetValue().(string),
})
},
)
}
m.searchInput.Blur()
} else if m.state == SEARCHING {
confirm, _ := m.searchInput.Update(msg)
if c, ok := confirm.(*huh.Input); ok {
m.searchInput = c
}
return nil, nil
} else if m.state == EDITING {
confirm, _ := m.editInput.Update(msg)
if c, ok := confirm.(*huh.Input); ok {
m.editInput = c
}
return nil, nil
}
case kadmin.TopicConfigListingStartedMsg:
m.state = LOADING
var cmd tea.Cmd
if m.updated {
cmd = nil
} else {
cmd = m.notifier.SpinWithLoadingMsg("Loading " + m.topic + " Topic Configs")
}
return msg, cmd
case kadmin.TopicConfigsListedMsg:
if m.updated {
m.updated = false
m.state = UPDATE_SUCCEEDED
} else {
m.state = HIDDEN
m.notifier.Idle()
}
return nil, nil
case kadmin.UpdateTopicConfigErrorMsg:
m.state = UPDATE_FAILED
m.notifier.ShowErrorMsg(msg.Reason, fmt.Errorf("TODO"))
return nil, nil
case kadmin.TopicConfigUpdatedMsg:
m.state = UPDATE_SUCCEEDED
m.notifier.ShowSuccessMsg("Update succeeded")
m.updated = true
return nil, func() tea.Msg { return m.topicConfigLister.ListConfigs(stc.Topic) }
case HideBarMsg:
m.state = HIDDEN
}
return msg, nil
}
func isEditable(m *CmdBarModel) bool {
return m.state == HIDDEN ||
m.state == SEARCHED ||
m.state == UPDATE_FAILED ||
m.state == UPDATE_SUCCEEDED
}
func (m *CmdBarModel) IsFocused() bool {
return !(m.state == HIDDEN ||
m.state == SEARCHED ||
m.state == UPDATE_FAILED ||
m.state == UPDATE_SUCCEEDED)
}
func (m *CmdBarModel) GetSearchTerm() string {
return m.searchInput.GetValue().(string)
}
func (m *CmdBarModel) IsLoading() bool {
return m.state == LOADING || m.state == UPDATING
}
func newSearchInput() *huh.Input {
searchInput := huh.NewInput().
Placeholder("Search for Config")
searchInput.Init()
return searchInput
}
func newEditInput(v string) *huh.Input {
searchInput := huh.NewInput().
Value(&v).
Placeholder("New config value")
searchInput.Init()
return searchInput
}
func NewCmdBar(cu kadmin.ConfigUpdater, tcl kadmin.TopicConfigLister, topic string) *CmdBarModel {
return &CmdBarModel{
topic: topic,
searchInput: newSearchInput(),
notifier: notifier.New(),
state: HIDDEN,
configUpdater: cu,
topicConfigLister: tcl,
}
}
package configs_page
import (
"fmt"
"ktea/kadmin"
"ktea/kontext"
"ktea/styles"
"ktea/ui"
"ktea/ui/components/statusbar"
"sort"
"strings"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type Model struct {
rows []table.Row
table *table.Model
cmdBar *CmdBarModel
configs map[string]string
topic string
err error
}
func (m *Model) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
var views []string
cmdBarView := m.cmdBar.View(ktx, renderer)
views = append(views, cmdBarView)
// TODO errors should not be checked here
//if m.err != nil {
// builder.WriteString(m.err.Error())
//}
m.table.SetColumns([]table.Column{
{Title: "Config", Width: int(float64(ktx.WindowWidth-5) * 0.5)},
{Title: "Value", Width: int(float64(ktx.WindowWidth-5) * 0.5)},
})
m.table.SetHeight(ktx.AvailableHeight - 2)
m.table.SetRows(m.rows)
m.table.Focus()
borderedView := styles.Borderize(m.table.View(), m.cmdBar.IsFocused(), nil)
views = append(views, borderedView)
return ui.JoinVertical(lipgloss.Top, views...)
}
func (m *Model) Update(msg tea.Msg) tea.Cmd {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
if m.cmdBar.IsLoading() {
return nil
}
selectedRow := m.table.SelectedRow()
var config SelectedTopicConfig
if selectedRow != nil {
config = SelectedTopicConfig{
Topic: m.topic,
ConfigKey: selectedRow[0],
ConfigValue: selectedRow[1],
}
}
um, c := m.cmdBar.Update(msg, config)
if c != nil {
cmds = append(cmds, c)
}
if um != nil {
t, c := m.table.Update(um)
if c != nil {
cmds = append(cmds, c)
}
m.table = &t
}
case kadmin.TopicConfigListingStartedMsg:
_, cmd := m.cmdBar.Update(msg, SelectedTopicConfig{})
return tea.Batch(cmd, msg.AwaitCompletion)
case kadmin.TopicConfigsListedMsg:
m.cmdBar.Update(msg, SelectedTopicConfig{})
m.configs = msg.Configs
default:
selectedRow := m.table.SelectedRow()
var config SelectedTopicConfig
if selectedRow != nil {
config = SelectedTopicConfig{
Topic: m.topic,
ConfigKey: selectedRow[0],
ConfigValue: selectedRow[1],
}
}
_, c := m.cmdBar.Update(msg, config)
return c
}
keys := make([]string, 0, len(m.configs))
for k := range m.configs {
if m.cmdBar.GetSearchTerm() != "" {
if strings.Contains(strings.ToLower(k), strings.ToLower(m.cmdBar.GetSearchTerm())) {
keys = append(keys, k)
}
} else {
keys = append(keys, k)
}
}
sort.Strings(keys)
var rows []table.Row
for _, k := range keys {
rows = append(rows, table.Row{k, m.configs[k]})
}
m.rows = rows
return tea.Batch(cmds...)
}
func (m *Model) Shortcuts() []statusbar.Shortcut {
return []statusbar.Shortcut{
{"Search", "/"},
{"Edit", "e"},
{"Go Back", "esc"},
}
}
func (m *Model) Title() string {
return fmt.Sprintf("Topics / %s / Configuration", m.topic)
}
func New(configUpdater kadmin.ConfigUpdater, topicConfigLister kadmin.TopicConfigLister, topic string) (*Model, tea.Cmd) {
m := &Model{}
m.cmdBar = NewCmdBar(configUpdater, topicConfigLister, topic)
t := table.New(
table.WithStyles(styles.Table.Styles),
)
m.table = &t
m.topic = topic
return m, func() tea.Msg { return topicConfigLister.ListConfigs(topic) }
}
package consumption_form_page
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"ktea/kadmin"
"ktea/kontext"
"ktea/styles"
"ktea/ui"
"ktea/ui/components/statusbar"
"ktea/ui/pages/nav"
"strconv"
)
type selectionState int
const (
notSelected selectionState = iota
selected
)
type Model struct {
form *huh.Form
formValues *formValues
windowResized bool
keyFilterSelectionState selectionState
valueFilterSelectionState selectionState
ktx *kontext.ProgramKtx
availableHeight int
topic *kadmin.ListedTopic
}
type formValues struct {
startPoint kadmin.StartPoint
limit int
partitions []int
keyFilter kadmin.FilterType
keyFilterTerm string
valueFilter kadmin.FilterType
valueFilterTerm string
}
func (m *Model) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
if m.form == nil {
m.availableHeight = ktx.AvailableHeight
m.form = m.newForm(m.topic.PartitionCount, m.ktx)
}
if m.windowResized {
m.windowResized = false
m.availableHeight = ktx.AvailableHeight
m.form = m.newForm(m.topic.PartitionCount, ktx)
}
return renderer.RenderWithStyle(m.form.View(), styles.Form)
}
func (m *Model) Update(msg tea.Msg) tea.Cmd {
if m.form == nil {
return nil
}
form, cmd := m.form.Update(msg)
if f, ok := form.(*huh.Form); ok {
m.form = f
}
if m.formValues.keyFilter != kadmin.NoFilterType && m.keyFilterSelectionState == notSelected {
// if key filter type is selected and previously not selected
m.keyFilterSelectionState = selected
m.form = m.newForm(m.topic.PartitionCount, m.ktx)
m.NextField(3)
m.form.NextGroup()
} else if m.formValues.keyFilter == kadmin.NoFilterType && m.keyFilterSelectionState == selected {
// if no key filter type is selected and previously selected
m.keyFilterSelectionState = notSelected
m.form = m.newForm(m.topic.PartitionCount, m.ktx)
m.NextField(3)
m.form.NextGroup()
}
if m.formValues.valueFilter != kadmin.NoFilterType && m.valueFilterSelectionState == notSelected {
// if value filter type is selected and previously not selected
m.valueFilterSelectionState = selected
m.form = m.newForm(m.topic.PartitionCount, m.ktx)
m.NextField(3)
m.form.NextGroup()
m.NextField(1)
} else if m.formValues.valueFilter == kadmin.NoFilterType && m.valueFilterSelectionState == selected {
// if no key filter type is selected and previously selected
m.valueFilterSelectionState = notSelected
m.form = m.newForm(m.topic.PartitionCount, m.ktx)
m.NextField(3)
m.form.NextGroup()
m.NextField(1)
}
switch msg.(type) {
case tea.WindowSizeMsg:
m.windowResized = true
}
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyEsc:
return ui.PublishMsg(nav.LoadTopicsPageMsg{})
}
}
filter := kadmin.Filter{}
if m.formValues.keyFilter != kadmin.NoFilterType {
filter.KeySearchTerm = m.formValues.keyFilterTerm
filter.KeyFilter = m.formValues.keyFilter
}
if m.formValues.valueFilter != kadmin.NoFilterType {
filter.ValueSearchTerm = m.formValues.valueFilterTerm
filter.ValueFilter = m.formValues.valueFilter
}
if m.form.State == huh.StateCompleted {
return m.submit(filter)
}
return cmd
}
func (m *Model) submit(filter kadmin.Filter) tea.Cmd {
var partToConsume []int
if m.noPartitionsSelected() {
// consume from all partitions
partToConsume = m.topic.Partitions()
} else {
partToConsume = m.formValues.partitions
}
return ui.PublishMsg(nav.LoadConsumptionPageMsg{
Topic: m.topic,
ReadDetails: kadmin.ReadDetails{
TopicName: m.topic.Name,
PartitionToRead: partToConsume,
StartPoint: m.formValues.startPoint,
Limit: m.formValues.limit,
Filter: &filter,
},
})
}
func (m *Model) noPartitionsSelected() bool {
return len(m.formValues.partitions) == 0
}
func (m *Model) Shortcuts() []statusbar.Shortcut {
return []statusbar.Shortcut{
{"Confirm", "enter"},
{"Next Field", "tab"},
{"Prev. Field", "s-tab"},
{"Select Partition", "space"},
{"Go Back", "esc"},
}
}
func (m *Model) Title() string {
return "Consumption details"
}
func (m *Model) newForm(partitions int, ktx *kontext.ProgramKtx) *huh.Form {
var partOptions []huh.Option[int]
for i := 0; i < partitions; i++ {
partOptions = append(partOptions, huh.NewOption[int](strconv.Itoa(i), i))
}
optionsHeight := 13 // 12 fixed height of form minus partitions field + padding and margins
if len(partOptions) < 13 {
optionsHeight = len(partOptions) + 2 // 2 for field title + padding
} else {
optionsHeight = m.availableHeight - optionsHeight
}
topicGroup := huh.NewGroup(
huh.NewSelect[kadmin.StartPoint]().
Value(&m.formValues.startPoint).
Title("Start form").
Options(
huh.NewOption("Beginning", kadmin.Beginning),
huh.NewOption("Most Recent", kadmin.MostRecent)),
huh.NewMultiSelect[int]().
Value(&m.formValues.partitions).
Height(optionsHeight).
Title("Partitions").
Description(m.getPartitionDescription(ktx)).
Options(partOptions...),
huh.NewSelect[int]().
Value(&m.formValues.limit).
Title("Limit").
Options(
huh.NewOption("50", 50),
huh.NewOption("500", 500),
huh.NewOption("5000", 5000)),
)
filterGroup := m.createFilterGroup()
form := huh.NewForm(
topicGroup.WithWidth(ktx.WindowWidth/2),
filterGroup,
)
form.WithLayout(huh.LayoutColumns(2))
form.Init()
return form
}
func (m *Model) createFilterGroup() *huh.Group {
var fields []huh.Field
fields = append(fields, m.keyFilterTypeField())
if m.formValues.keyFilter != kadmin.NoFilterType {
fields = append(fields, m.keyFilterTermField())
}
fields = append(fields, m.valueFilterTypeField())
if m.formValues.valueFilter != kadmin.NoFilterType {
fields = append(fields, m.valueFilterTermField())
}
return huh.NewGroup(fields...)
}
func (m *Model) valueFilterTermField() *huh.Input {
return huh.NewInput().
Value(&m.formValues.valueFilterTerm).
Title("Value Filter Term")
}
func (m *Model) valueFilterTypeField() *huh.Select[kadmin.FilterType] {
return huh.NewSelect[kadmin.FilterType]().
Value(&m.formValues.valueFilter).
Title("Value Filter Type").
Options(
huh.NewOption("None", kadmin.NoFilterType),
huh.NewOption("Contains", kadmin.ContainsFilterType),
huh.NewOption("Starts With", kadmin.StartsWithFilterType))
}
func (m *Model) keyFilterTermField() *huh.Input {
return huh.NewInput().
Value(&m.formValues.keyFilterTerm).
Title("Key Filter Term")
}
func (m *Model) keyFilterTypeField() *huh.Select[kadmin.FilterType] {
return huh.NewSelect[kadmin.FilterType]().
Value(&m.formValues.keyFilter).
Title("Key Filter Type").
Options(
huh.NewOption("None", kadmin.NoFilterType),
huh.NewOption("Contains", kadmin.ContainsFilterType),
huh.NewOption("Starts With", kadmin.StartsWithFilterType))
}
// hack until https://github.com/charmbracelet/huh/issues/525 has been resolved
func (m *Model) getPartitionDescription(ktx *kontext.ProgramKtx) string {
partitionDescription := "Select none to consume from all available partitions"
columnWidth := ktx.WindowWidth / 2
extraSpaces := columnWidth - lipgloss.Width(partitionDescription)
for i := 0; i < extraSpaces; i++ {
partitionDescription += " "
}
return partitionDescription
}
func (m *Model) NextField(count int) {
for i := 0; i < count; i++ {
m.form.NextField()
}
}
func NewWithDetails(
details *kadmin.ReadDetails,
topic *kadmin.ListedTopic,
ktx *kontext.ProgramKtx,
) *Model {
var partitionsToRead []int
if topic.PartitionCount != len(details.PartitionToRead) {
partitionsToRead = details.PartitionToRead
}
return &Model{
ktx: ktx,
topic: topic,
formValues: &formValues{
startPoint: details.StartPoint,
limit: details.Limit,
partitions: partitionsToRead,
keyFilter: details.Filter.KeyFilter,
keyFilterTerm: details.Filter.KeySearchTerm,
valueFilter: details.Filter.ValueFilter,
valueFilterTerm: details.Filter.ValueSearchTerm,
}}
}
func New(topic *kadmin.ListedTopic, ktx *kontext.ProgramKtx) *Model {
return &Model{
topic: topic,
formValues: &formValues{},
ktx: ktx,
}
}
package consumption_page
import (
tea "github.com/charmbracelet/bubbletea"
"ktea/kadmin"
"ktea/kontext"
"ktea/ui"
"ktea/ui/components/cmdbar"
"ktea/ui/components/notifier"
"ktea/ui/components/statusbar"
"ktea/ui/pages/nav"
)
type ConsumptionCmdBar struct {
notifierWidget cmdbar.CmdBar
active cmdbar.CmdBar
}
func (c *ConsumptionCmdBar) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
if c.active != nil {
return renderer.Render(c.active.View(ktx, renderer))
}
return renderer.Render("")
}
func (c *ConsumptionCmdBar) Update(msg tea.Msg) tea.Cmd {
// when notifier is active it is receiving priority to handle messages
// until a message comes in that deactivates the notifier
if c.active == c.notifierWidget {
c.active = c.notifierWidget
active, _, cmd := c.active.Update(msg)
if !active {
c.active = nil
}
return cmd
}
switch msg := msg.(type) {
case kadmin.ReadingStartedMsg:
c.active = c.notifierWidget
_, _, cmd := c.active.Update(msg)
return cmd
}
return nil
}
func (c *ConsumptionCmdBar) Shortcuts() []statusbar.Shortcut {
if c.active == nil {
return nil
}
return c.active.Shortcuts()
}
func NewConsumptionCmdbar() *ConsumptionCmdBar {
readingStartedNotifier := func(msg kadmin.ReadingStartedMsg, m *notifier.Model) (bool, tea.Cmd) {
return true, m.SpinWithLoadingMsg("Consuming")
}
c := func(msg nav.LoadCachedConsumptionPageMsg, m *notifier.Model) (bool, tea.Cmd) {
return true, m.SpinWithLoadingMsg("Consuming")
}
consumptionEndedNotifier := func(msg ConsumptionEndedMsg, m *notifier.Model) (bool, tea.Cmd) {
m.Idle()
return false, nil
}
emptyTopicMsgHandler := func(_ EmptyTopicMsg, m *notifier.Model) (bool, tea.Cmd) {
m.Idle()
return false, nil
}
notifierCmdBar := cmdbar.NewNotifierCmdBar("consumption-bar")
cmdbar.WithMsgHandler(notifierCmdBar, readingStartedNotifier)
cmdbar.WithMsgHandler(notifierCmdBar, consumptionEndedNotifier)
cmdbar.WithMsgHandler(notifierCmdBar, emptyTopicMsgHandler)
cmdbar.WithMsgHandler(notifierCmdBar, c)
return &ConsumptionCmdBar{
notifierWidget: notifierCmdBar,
}
}
package consumption_page
import (
"context"
"fmt"
"ktea/kadmin"
"ktea/kontext"
"ktea/styles"
"ktea/ui"
"ktea/ui/components/statusbar"
"ktea/ui/pages/nav"
"strconv"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type Model struct {
table *table.Model
cmdBar *ConsumptionCmdBar
consumerRecordChan chan kadmin.ConsumerRecord
emptyTopicChan chan bool
cancelConsumption context.CancelFunc
errChan chan error
reader kadmin.RecordReader
rows []table.Row
records []kadmin.ConsumerRecord
readDetails kadmin.ReadDetails
consuming bool
noRecordsAvailable bool
topic *kadmin.ListedTopic
}
type ConsumerRecordReceived struct {
Record kadmin.ConsumerRecord
}
type ConsumptionEndedMsg struct{}
type EmptyTopicMsg struct {
}
func (m *Model) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
var views []string
views = append(views, m.cmdBar.View(ktx, renderer))
if m.noRecordsAvailable {
views = append(views, styles.CenterText(ktx.WindowWidth, ktx.AvailableHeight).
Render("👀 Empty topic"))
} else if len(m.rows) > 0 {
m.table.SetColumns([]table.Column{
{Title: "Key", Width: int(float64(ktx.WindowWidth-9) * 0.5)},
{Title: "Timestamp", Width: int(float64(ktx.WindowWidth-9) * 0.30)},
{Title: "Partition", Width: int(float64(ktx.WindowWidth-9) * 0.10)},
{Title: "Offset", Width: int(float64(ktx.WindowWidth-9) * 0.10)},
})
m.table.SetHeight(ktx.AvailableHeight - 2)
m.table.SetRows(m.rows)
embeddedText := map[styles.BorderPosition]styles.EmbeddedTextFunc{
styles.TopMiddleBorder: func(active bool) string {
return lipgloss.NewStyle().
Foreground(lipgloss.Color(styles.ColorPink)).
Bold(true).
Render(fmt.Sprintf("Records: %d", len(m.rows)))
},
}
borderedView := styles.Borderize(m.table.View(), true, embeddedText)
views = append(views, borderedView)
}
return ui.JoinVertical(lipgloss.Top, views...)
}
func (m *Model) Update(msg tea.Msg) tea.Cmd {
var cmds []tea.Cmd
cmd := m.cmdBar.Update(msg)
cmds = append(cmds, cmd)
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "esc" {
m.cancelConsumption()
if m.readDetails.StartPoint == kadmin.Live {
return ui.PublishMsg(nav.LoadTopicsPageMsg{})
}
return ui.PublishMsg(nav.LoadConsumptionFormPageMsg{ReadDetails: &m.readDetails, Topic: m.topic})
} else if msg.String() == "f2" {
m.cancelConsumption()
m.consuming = false
cmds = append(cmds, ui.PublishMsg(ConsumptionEndedMsg{}))
} else if msg.String() == "enter" {
if len(m.records) > 0 {
selectedRow := m.records[len(m.records)-m.table.Cursor()-1]
m.consuming = false
return ui.PublishMsg(nav.LoadRecordDetailPageMsg{
Record: &selectedRow,
TopicName: m.readDetails.TopicName,
})
}
} else {
t, cmd := m.table.Update(msg)
m.table = &t
cmds = append(cmds, cmd)
}
case EmptyTopicMsg:
m.noRecordsAvailable = true
m.consuming = false
case kadmin.ReadingStartedMsg:
m.consuming = true
m.consumerRecordChan = msg.ConsumerRecord
m.emptyTopicChan = msg.EmptyTopic
m.errChan = msg.Err
cmds = append(cmds, m.waitForActivity())
case ConsumptionEndedMsg:
m.consuming = false
return nil
case ConsumerRecordReceived:
var key string
if msg.Record.Key == "" {
key = "<null>"
} else {
key = msg.Record.Key
}
m.records = append(m.records, msg.Record)
m.rows = append(
[]table.Row{
{
key,
msg.Record.Timestamp.Format("2006-01-02 15:04:05"),
strconv.FormatInt(msg.Record.Partition, 10),
strconv.FormatInt(msg.Record.Offset, 10),
},
},
m.rows...,
)
return m.waitForActivity()
}
return tea.Batch(cmds...)
}
func (m *Model) waitForActivity() tea.Cmd {
return func() tea.Msg {
select {
case record, ok := <-m.consumerRecordChan:
if !ok {
return ConsumptionEndedMsg{}
}
return ConsumerRecordReceived{Record: record}
case <-m.emptyTopicChan:
return EmptyTopicMsg{}
case err := <-m.errChan:
return err
}
}
}
func (m *Model) Shortcuts() []statusbar.Shortcut {
if m.consuming {
return []statusbar.Shortcut{
{"View Record", "enter"},
{"Stop consuming", "F2"},
{"Go Back", "esc"},
}
} else if m.noRecordsAvailable {
return []statusbar.Shortcut{
{"Go Back", "esc"},
}
} else {
return []statusbar.Shortcut{
{"View Record", "enter"},
{"Go Back", "esc"},
}
}
}
func (m *Model) Title() string {
return "Topics / " + m.readDetails.TopicName + " / Records"
}
func New(
reader kadmin.RecordReader,
readDetails kadmin.ReadDetails,
topic *kadmin.ListedTopic,
) (nav.Page, tea.Cmd) {
m := &Model{}
t := table.New(
table.WithFocused(true),
table.WithStyles(styles.Table.Styles),
)
m.table = &t
m.reader = reader
m.cmdBar = NewConsumptionCmdbar()
m.readDetails = readDetails
m.topic = topic
ctx, cancelFunc := context.WithCancel(context.Background())
m.cancelConsumption = cancelFunc
return m, func() tea.Msg {
return m.reader.ReadRecords(ctx, readDetails)
}
}
package create_cluster_page
import (
"errors"
"fmt"
"ktea/config"
"ktea/kadmin"
"ktea/kcadmin"
"ktea/kontext"
"ktea/sradmin"
"ktea/styles"
"ktea/ui"
"ktea/ui/components/border"
"ktea/ui/components/cmdbar"
"ktea/ui/components/notifier"
"ktea/ui/components/statusbar"
"reflect"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/log"
)
type authSelection int
type formState int
type Option func(m *Model)
const (
noneSelected authSelection = 0
saslSelected authSelection = 1
nothingSelected authSelection = 2
none formState = 0
loading formState = 1
notifierCmdbarTag = "upsert-cluster-page"
cTab border.TabLabel = "f4"
srTab border.TabLabel = "f5"
kcTab border.TabLabel = "f6"
)
type Model struct {
NavBack ui.NavBack
form *huh.Form // the active form
state formState
srForm *huh.Form
cForm *huh.Form
kForm *huh.Form
clusterValues *clusterValues
clusterToEdit *config.Cluster
notifierCmdBar *cmdbar.NotifierCmdBar
ktx *kontext.ProgramKtx
clusterRegisterer config.ClusterRegisterer
kConnChecker kadmin.ConnChecker
srConnChecker sradmin.ConnChecker
authSelectionState authSelection
preEditName *string
shortcuts []statusbar.Shortcut
title string
border *border.Model
kcModel *UpsertKcModel
}
type clusterValues struct {
name string
color string
host string
authMethod config.AuthMethod
securityProtocol config.SecurityProtocol
sslEnabled bool
username string
password string
srUrl string
srUsername string
srPassword string
}
func (m *Model) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
var views []string
if !ktx.Config.HasClusters() {
builder := strings.Builder{}
builder.WriteString("\n")
builder.WriteString(lipgloss.NewStyle().PaddingLeft(1).Render("No clusters configured. Please create your first cluster!"))
builder.WriteString("\n")
views = append(views, renderer.Render(builder.String()))
}
notifierView := m.notifierCmdBar.View(ktx, renderer)
deleteCmdbar := ""
if m.kcModel.deleteCmdbar.IsFocussed() {
deleteCmdbar = m.kcModel.deleteCmdbar.View(ktx, renderer)
}
var mainView string
if m.border.ActiveTab() == kcTab {
mainView = renderer.Render(lipgloss.
NewStyle().
Width(ktx.WindowWidth - 2).
Render(m.kcModel.View(ktx, renderer)))
} else {
mainView = renderer.RenderWithStyle(m.form.View(), styles.Form)
}
mainView = m.border.View(lipgloss.NewStyle().
PaddingBottom(ktx.AvailableHeight - 1).
Render(mainView))
views = append(views, deleteCmdbar, notifierView, mainView)
return ui.JoinVertical(lipgloss.Top, views...)
}
func (m *Model) Update(msg tea.Msg) tea.Cmd {
log.Debug("Received Update", "msg", reflect.TypeOf(msg))
var cmds []tea.Cmd
activeTab := m.border.ActiveTab()
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "esc":
if activeTab == kcTab {
return m.kcModel.Update(msg)
}
m.title = "Clusters"
return m.NavBack()
case "ctrl+r":
m.clusterValues = &clusterValues{}
if activeTab == cTab {
m.cForm = m.createCForm()
m.form = m.cForm
m.authSelectionState = noneSelected
} else {
m.srForm = m.createSrForm()
m.form = m.srForm
}
case "f4":
m.form = m.cForm
m.border.GoTo("f4")
return nil
case "f5":
m.border.GoTo("handling f5")
if m.inEditingMode() {
m.form = m.srForm
m.form.State = huh.StateNormal
m.border.GoTo("f5")
log.Debug("go to f5")
return nil
} else {
log.Debug("not in edit")
return tea.Batch(
m.notifierCmdBar.Notifier.ShowError(fmt.Errorf("create a cluster before adding a schema registry")),
m.notifierCmdBar.Notifier.AutoHideCmd(notifierCmdbarTag),
)
}
case "f6":
if m.inEditingMode() {
m.form.State = huh.StateNormal
m.border.GoTo("f6")
return nil
} else {
return tea.Batch(
m.notifierCmdBar.Notifier.ShowError(fmt.Errorf("create a cluster before adding a Kafka Connect Cluster")),
m.notifierCmdBar.Notifier.AutoHideCmd(notifierCmdbarTag),
)
}
}
case kadmin.ConnCheckStartedMsg:
m.state = loading
cmds = append(cmds, msg.AwaitCompletion)
case kadmin.ConnCheckSucceededMsg:
m.state = none
cmds = append(cmds, m.registerCluster)
case sradmin.ConnCheckStartedMsg:
m.state = loading
cmds = append(cmds, msg.AwaitCompletion)
case sradmin.ConnCheckSucceededMsg:
m.state = none
return m.registerCluster
case config.ClusterRegisteredMsg:
m.preEditName = &msg.Cluster.Name
m.clusterToEdit = msg.Cluster
m.state = none
m.border.WithInActiveColor(styles.ColorGrey)
if activeTab == cTab {
m.cForm = m.createCForm()
m.form = m.cForm
} else if activeTab == srTab {
m.srForm = m.createSrForm()
m.form = m.srForm
} else {
m.kcModel.Update(msg)
}
}
if activeTab == kcTab {
cmd := m.kcModel.Update(msg)
if cmd != nil {
cmds = append(cmds, cmd)
}
}
_, msg, cmd := m.notifierCmdBar.Update(msg)
if cmd != nil {
cmds = append(cmds, cmd)
}
if msg == nil {
return tea.Batch(cmds...)
}
if activeTab == cTab || activeTab == srTab {
form, cmd := m.form.Update(msg)
cmds = append(cmds, cmd)
if f, ok := form.(*huh.Form); ok {
m.form = f
}
}
if activeTab == cTab {
if !m.clusterValues.HasSASLAuthMethodSelected() &&
m.authSelectionState == saslSelected {
// if SASL authentication mode was previously selected and switched back to none
m.cForm = m.createCForm()
m.form = m.cForm
m.NextField(4)
m.authSelectionState = noneSelected
} else if m.clusterValues.HasSASLAuthMethodSelected() &&
(m.authSelectionState == nothingSelected || m.authSelectionState == noneSelected) {
// SASL authentication mode selected and previously nothing or none auth mode was selected
m.cForm = m.createCForm()
m.form = m.cForm
m.NextField(4)
m.authSelectionState = saslSelected
}
if m.form.State == huh.StateCompleted && m.state != loading {
return m.processClusterSubmission()
}
}
if activeTab == srTab {
if m.form.State == huh.StateCompleted && m.state != loading {
return m.processSrSubmission()
}
}
return tea.Batch(cmds...)
}
func (m *Model) registerCluster() tea.Msg {
details := m.getRegistrationDetails()
return m.clusterRegisterer.RegisterCluster(details)
}
func (m *Model) Shortcuts() []statusbar.Shortcut {
if m.border.ActiveTab() == kcTab {
return m.kcModel.Shortcuts()
}
return m.shortcuts
}
func (m *Model) Title() string {
if m.title == "" {
return "Clusters / Create"
}
return m.title
}
func (m *Model) processSrSubmission() tea.Cmd {
m.state = loading
details := m.getRegistrationDetails()
cluster := config.ToCluster(details)
return func() tea.Msg {
return m.srConnChecker(cluster.SchemaRegistry)
}
}
func (m *Model) processClusterSubmission() tea.Cmd {
m.state = loading
details := m.getRegistrationDetails()
cluster := config.ToCluster(details)
return func() tea.Msg {
return m.kConnChecker(&cluster)
}
}
func (m *Model) getRegistrationDetails() config.RegistrationDetails {
var name string
var newName *string
if m.preEditName == nil { // When creating a cluster
name = m.clusterValues.name
newName = nil
} else { // When updating a cluster.
name = *m.preEditName
if m.clusterValues.name != *m.preEditName {
newName = &m.clusterValues.name
}
}
var authMethod config.AuthMethod
var securityProtocol config.SecurityProtocol
if m.clusterValues.HasSASLAuthMethodSelected() {
authMethod = config.SASLAuthMethod
securityProtocol = m.clusterValues.securityProtocol
} else {
authMethod = config.NoneAuthMethod
}
details := config.RegistrationDetails{
Name: name,
NewName: newName,
Color: m.clusterValues.color,
Host: m.clusterValues.host,
AuthMethod: authMethod,
SecurityProtocol: securityProtocol,
SSLEnabled: m.clusterValues.sslEnabled,
Username: m.clusterValues.username,
Password: m.clusterValues.password,
}
if m.clusterValues.SrEnabled() {
details.SchemaRegistry = &config.SchemaRegistryDetails{
Url: m.clusterValues.srUrl,
Username: m.clusterValues.srUsername,
Password: m.clusterValues.srPassword,
}
}
details.KafkaConnectClusters = m.kcModel.clusterDetails()
return details
}
func (f *clusterValues) HasSASLAuthMethodSelected() bool {
return f.authMethod == config.SASLAuthMethod
}
func (f *clusterValues) SrEnabled() bool {
return len(f.srUrl) > 0
}
func (m *Model) NextField(count int) {
for i := 0; i < count; i++ {
m.form.NextField()
}
}
func (m *Model) createCForm() *huh.Form {
name := huh.NewInput().
Value(&m.clusterValues.name).
Title("Name").
Validate(func(v string) error {
if v == "" {
return errors.New("name cannot be empty")
}
if m.preEditName != nil {
// When updating.
if m.ktx.Config.FindClusterByName(v) != nil && v != *m.preEditName {
return errors.New("cluster " + v + " already exists, name most be unique")
}
} else {
// When creating a new cluster
if m.ktx.Config.FindClusterByName(v) != nil {
return errors.New("cluster " + v + " already exists, name most be unique")
}
}
return nil
})
color := huh.NewSelect[string]().
Value(&m.clusterValues.color).
Title("Color").
Options(
huh.NewOption(styles.Env.Colors.Green.Render("green"), styles.ColorGreen),
huh.NewOption(styles.Env.Colors.Blue.Render("blue"), styles.ColorBlue),
huh.NewOption(styles.Env.Colors.Orange.Render("orange"), styles.ColorOrange),
huh.NewOption(styles.Env.Colors.Purple.Render("purple"), styles.ColorPurple),
huh.NewOption(styles.Env.Colors.Yellow.Render("yellow"), styles.ColorYellow),
huh.NewOption(styles.Env.Colors.Red.Render("red"), styles.ColorRed),
)
host := huh.NewInput().
Value(&m.clusterValues.host).
Title("Host").
Validate(func(v string) error {
if v == "" {
return errors.New("host cannot be empty")
}
return nil
})
auth := huh.NewSelect[config.AuthMethod]().
Value(&m.clusterValues.authMethod).
Title("Authentication method").
Options(
huh.NewOption("NONE", config.NoneAuthMethod),
huh.NewOption("SASL", config.SASLAuthMethod),
)
sslEnabled := huh.NewSelect[bool]().
Value(&m.clusterValues.sslEnabled).
Title("SSL").
Options(
huh.NewOption("Disable SSL", false),
huh.NewOption("Enable SSL", true),
)
var clusterFields []huh.Field
clusterFields = append(clusterFields, name, color, host, sslEnabled, auth)
if m.clusterValues.HasSASLAuthMethodSelected() {
securityProtocol := huh.NewSelect[config.SecurityProtocol]().
Value(&m.clusterValues.securityProtocol).
Title("Security Protocol").
Options(
huh.NewOption("SASL_PLAINTEXT", config.SASLPlaintextSecurityProtocol),
)
username := huh.NewInput().
Value(&m.clusterValues.username).
Title("Username")
pwd := huh.NewInput().
Value(&m.clusterValues.password).
EchoMode(huh.EchoModePassword).
Title("Password")
clusterFields = append(clusterFields, securityProtocol, username, pwd)
}
form := huh.NewForm(
huh.NewGroup(clusterFields...).
Title("Cluster").
WithWidth(m.ktx.WindowWidth - 3),
)
form.QuitAfterSubmit = false
form.Init()
return form
}
func (m *Model) createSrForm() *huh.Form {
var fields []huh.Field
srUrl := huh.NewInput().
Value(&m.clusterValues.srUrl).
Title("Schema Registry URL")
srUsername := huh.NewInput().
Value(&m.clusterValues.srUsername).
Title("Schema Registry Username")
srPwd := huh.NewInput().
Value(&m.clusterValues.srPassword).
EchoMode(huh.EchoModePassword).
Title("Schema Registry Password")
fields = append(fields, srUrl, srUsername, srPwd)
form := huh.NewForm(
huh.NewGroup(fields...).
Title("Schema Registry").
WithWidth(m.ktx.WindowWidth - 3),
)
form.QuitAfterSubmit = false
form.Init()
return form
}
func (m *Model) createNotifierCmdBar() {
m.notifierCmdBar = cmdbar.NewNotifierCmdBar(notifierCmdbarTag)
cmdbar.WithMsgHandler(m.notifierCmdBar, func(msg kadmin.ConnCheckStartedMsg, m *notifier.Model) (bool, tea.Cmd) {
return true, m.SpinWithLoadingMsg("Testing cluster connectivity")
})
cmdbar.WithMsgHandler(m.notifierCmdBar, func(msg kadmin.ConnCheckSucceededMsg, m *notifier.Model) (bool, tea.Cmd) {
return true, m.SpinWithLoadingMsg("Connection success creating cluster")
})
cmdbar.WithMsgHandler(m.notifierCmdBar, func(msg kadmin.ConnCheckErrMsg, nm *notifier.Model) (bool, tea.Cmd) {
m.cForm = m.createCForm()
m.form = m.cForm
m.state = none
nMsg := "Cluster not crated"
if m.inEditingMode() {
nMsg = "Cluster not updated"
}
return true, nm.ShowErrorMsg(nMsg, msg.Err)
})
cmdbar.WithMsgHandler(m.notifierCmdBar, func(msg config.ClusterRegisteredMsg, nm *notifier.Model) (bool, tea.Cmd) {
if m.form == m.srForm {
nm.ShowSuccessMsg("Schema registry registered! <ESC> to go back.")
} else if m.form == m.cForm {
if m.inEditingMode() {
nm.ShowSuccessMsg("Cluster updated!")
} else {
nm.ShowSuccessMsg("Cluster registered! <ESC> to go back or <F5> to add a schema registry.")
}
} else {
nm.ShowSuccessMsg("Cluster registered!")
}
return true, nm.AutoHideCmd(notifierCmdbarTag)
})
cmdbar.WithMsgHandler(m.notifierCmdBar, func(msg sradmin.ConnCheckErrMsg, nm *notifier.Model) (bool, tea.Cmd) {
m.srForm = m.createSrForm()
m.form = m.srForm
m.state = none
nm.ShowErrorMsg("unable to reach the schema registry", msg.Err)
return true, nm.AutoHideCmd(notifierCmdbarTag)
})
}
func (m *Model) inEditingMode() bool {
return m.clusterToEdit != nil
}
func WithTitle(title string) Option {
return func(m *Model) {
m.title = title
}
}
func initBorder(options ...border.Option) *border.Model {
return border.New(
append([]border.Option{
border.WithTabs(
border.Tab{Title: "Cluster ≪ F4 »", TabLabel: cTab},
border.Tab{Title: "Schema Registry ≪ F5 »", TabLabel: srTab},
border.Tab{Title: "Kafka Connect ≪ F6 »", TabLabel: kcTab},
),
}, options...)...)
}
func NewCreateClusterPage(
NavBack ui.NavBack,
kConnChecker kadmin.ConnChecker,
srConnChecker sradmin.ConnChecker,
registerer config.ClusterRegisterer,
ktx *kontext.ProgramKtx,
shortcuts []statusbar.Shortcut,
options ...Option,
) *Model {
formValues := &clusterValues{}
model := Model{
NavBack: NavBack,
clusterValues: formValues,
kConnChecker: kConnChecker,
srConnChecker: srConnChecker,
shortcuts: shortcuts,
}
model.ktx = ktx
model.border = initBorder(border.WithInactiveColor(styles.ColorDarkGrey))
model.cForm = model.createCForm()
model.srForm = model.createSrForm()
model.form = model.cForm
model.createNotifierCmdBar()
model.kcModel = NewUpsertKcModel(NavBack, ktx, nil, []config.KafkaConnectConfig{}, kcadmin.CheckKafkaConnectClustersConn, model.notifierCmdBar, model.registerCluster)
model.clusterRegisterer = registerer
model.authSelectionState = nothingSelected
model.state = none
if model.clusterValues.HasSASLAuthMethodSelected() {
model.authSelectionState = saslSelected
}
for _, option := range options {
option(&model)
}
return &model
}
func NewEditClusterPage(
back ui.NavBack,
kConnChecker kadmin.ConnChecker,
srConnChecker sradmin.ConnChecker,
registerer config.ClusterRegisterer,
connectClusterDeleter config.ConnectClusterDeleter,
ktx *kontext.ProgramKtx,
cluster config.Cluster,
options ...Option,
) *Model {
formValues := &clusterValues{
name: cluster.Name,
color: cluster.Color,
host: cluster.BootstrapServers[0],
}
if cluster.SASLConfig != nil {
formValues.securityProtocol = cluster.SASLConfig.SecurityProtocol
formValues.username = cluster.SASLConfig.Username
formValues.password = cluster.SASLConfig.Password
formValues.authMethod = config.SASLAuthMethod
formValues.sslEnabled = cluster.SSLEnabled
}
if cluster.SchemaRegistry != nil {
formValues.srUrl = cluster.SchemaRegistry.Url
formValues.srUsername = cluster.SchemaRegistry.Username
formValues.srPassword = cluster.SchemaRegistry.Password
}
model := Model{
NavBack: back,
clusterToEdit: &cluster,
clusterValues: formValues,
kConnChecker: kConnChecker,
srConnChecker: srConnChecker,
shortcuts: []statusbar.Shortcut{
{"Confirm", "enter"},
{"Next Field", "tab"},
{"Prev. Field", "s-tab"},
{"Reset Form", "C-r"},
{"Go Back", "esc"},
},
}
if cluster.Name != "" {
// copied to prevent model.preEditedName to follow the formValues.Name pointer
preEditedName := cluster.Name
model.preEditName = &preEditedName
}
model.ktx = ktx
model.border = initBorder(border.WithInactiveColor(styles.ColorGrey))
model.cForm = model.createCForm()
model.srForm = model.createSrForm()
model.form = model.cForm
model.createNotifierCmdBar()
model.kcModel = NewUpsertKcModel(
back,
ktx,
func(name string) tea.Msg {
return connectClusterDeleter.DeleteKafkaConnectCluster(cluster.Name, name)
},
cluster.KafkaConnectClusters,
kcadmin.CheckKafkaConnectClustersConn,
model.notifierCmdBar,
model.registerCluster,
)
model.clusterRegisterer = registerer
model.authSelectionState = nothingSelected
model.state = none
if model.clusterValues.HasSASLAuthMethodSelected() {
model.authSelectionState = saslSelected
}
for _, o := range options {
o(&model)
}
return &model
}
package create_cluster_page
import (
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/log"
"ktea/config"
"ktea/kcadmin"
"ktea/kontext"
"ktea/styles"
"ktea/ui"
"ktea/ui/components/cmdbar"
"ktea/ui/components/notifier"
"ktea/ui/components/statusbar"
ktable "ktea/ui/components/table"
"reflect"
)
type UpsertKcModel struct {
ktx *kontext.ProgramKtx
connectClusters []config.KafkaConnectConfig
connChecker kcadmin.ConnChecker
form *huh.Form
table table.Model
rows []table.Row
cmdBar *cmdbar.NotifierCmdBar
registerer tea.Cmd
formValues
state
deleteCmdbar *cmdbar.DeleteCmdBar[string]
back ui.NavBack
}
type state int
type formValues struct {
url string
username string
password string
name string
prevName string
}
func (f *formValues) usernameOrNil() *string {
var username *string
if f.username != "" {
username = &f.username
}
return username
}
func (f *formValues) passwordOrNil() *string {
var password *string
if f.password != "" {
password = &f.password
}
return password
}
const (
entering state = iota
listing
registering
)
func (m *UpsertKcModel) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
var views []string
if m.state == listing {
m.table.SetColumns([]table.Column{
{"Name", ktx.WindowWidth - 5},
})
m.table.SetHeight(ktx.AvailableHeight - 1)
m.table.SetWidth(ktx.WindowWidth - 2)
m.table.SetRows(m.rows)
views = append(views, renderer.Render(m.table.View()))
return ui.JoinVertical(lipgloss.Top, views...)
}
m.form.WithHeight(ktx.AvailableHeight - 3)
return renderer.RenderWithStyle(m.form.View(), styles.Form)
}
func (m *UpsertKcModel) Update(msg tea.Msg) tea.Cmd {
log.Debug("Received Update", "msg", reflect.TypeOf(msg))
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyEsc:
if m.state == entering {
if len(m.connectClusters) > 0 {
m.state = listing
}
} else {
m.back()
}
return nil
case tea.KeyF2:
clusterName := m.table.SelectedRow()[0]
m.deleteCmdbar.Delete(clusterName)
_, _, cmd := m.deleteCmdbar.Update(msg)
return cmd
case tea.KeyCtrlN:
m.state = entering
m.resetFormValues()
m.form = m.createKcForm()
case tea.KeyCtrlE:
if m.state == entering {
break
}
m.state = entering
clusterName := m.table.SelectedRow()[0]
for _, cluster := range m.connectClusters {
if clusterName == cluster.Name {
m.formValues.prevName = cluster.Name
m.formValues.name = cluster.Name
m.formValues.url = cluster.Url
if cluster.Username == nil {
m.formValues.username = ""
} else {
m.formValues.username = *cluster.Username
}
if cluster.Password == nil {
m.formValues.password = ""
} else {
m.formValues.password = *cluster.Password
}
m.form = m.createKcForm()
return nil
}
}
panic("Kafka Connect cluster not found: " + clusterName)
}
case kcadmin.ConnCheckStartedMsg:
return msg.AwaitCompletion
case kcadmin.ConnCheckSucceededMsg:
return m.registerer
case config.ClusterRegisteredMsg:
m.state = listing
m.connectClusters = msg.Cluster.KafkaConnectClusters
m.rows = m.createRows()
return nil
case config.ConnectClusterDeleted:
for i, cluster := range m.connectClusters {
if msg.Name == cluster.Name {
m.connectClusters = append(m.connectClusters[:i], m.connectClusters[i+1:]...)
}
}
m.rows = m.createRows()
if len(m.connectClusters) == 0 {
m.state = entering
}
m.deleteCmdbar.Hide()
return nil
}
if m.deleteCmdbar.IsFocussed() {
_, _, cmd := m.deleteCmdbar.Update(msg)
return cmd
}
if m.state == entering {
form, cmd := m.form.Update(msg)
if f, ok := form.(*huh.Form); ok {
m.form = f
}
if m.form.State == huh.StateCompleted {
return m.processFormSubmission()
}
return cmd
}
t, c := m.table.Update(msg)
m.table = t
return c
}
func (m *UpsertKcModel) processFormSubmission() tea.Cmd {
m.state = registering
kcConfig := config.KafkaConnectConfig{
Name: m.formValues.name,
Url: m.formValues.url,
Username: m.formValues.usernameOrNil(),
Password: m.formValues.passwordOrNil(),
}
return func() tea.Msg {
return m.connChecker(&kcConfig)
}
}
func (m *UpsertKcModel) resetFormValues() {
m.formValues.name = ""
m.formValues.url = ""
m.formValues.username = ""
m.formValues.password = ""
}
func (m *UpsertKcModel) clusterDetails() []config.KafkaConnectClusterDetails {
var details []config.KafkaConnectClusterDetails
var updated bool
for _, cluster := range m.connectClusters {
if m.formValues.prevName == cluster.Name && m.formValues.prevName != "" {
updated = true
details = append(details, config.KafkaConnectClusterDetails{
Name: m.formValues.name,
Url: m.formValues.url,
Username: m.formValues.usernameOrNil(),
Password: m.formValues.passwordOrNil(),
})
} else {
details = append(details, config.KafkaConnectClusterDetails{
Name: cluster.Name,
Url: cluster.Url,
Username: cluster.Username,
Password: cluster.Password,
})
}
}
if !updated && m.formValues.name != "" {
details = append(details, config.KafkaConnectClusterDetails{
Name: m.formValues.name,
Url: m.formValues.url,
Username: m.formValues.usernameOrNil(),
Password: m.formValues.passwordOrNil(),
})
}
return details
}
func (m *UpsertKcModel) Shortcuts() []statusbar.Shortcut {
return []statusbar.Shortcut{
{"Go back", "esc"},
{"Edit Connect Cluster", "C-e"},
{"New Connect Cluster", "C+n"},
{"Delete Connect Cluster", "F2"},
}
}
func (m *UpsertKcModel) Title() string {
//TODO implement me
panic("implement me")
}
func (m *UpsertKcModel) createRows() []table.Row {
var rows []table.Row
for _, c := range m.connectClusters {
rows = append(rows, table.Row{c.Name})
}
return rows
}
func (m *UpsertKcModel) createKcForm() *huh.Form {
var fields []huh.Field
name := huh.NewInput().
Value(&m.formValues.name).
Title("Kafka Connect Name")
url := huh.NewInput().
Value(&m.formValues.url).
Title("Kafka Connect URL")
username := huh.NewInput().
Value(&m.formValues.username).
Title("Kafka Connect Username")
password := huh.NewInput().
Value(&m.formValues.password).
EchoMode(huh.EchoModePassword).
Title("Kafka Connect Password")
fields = append(fields, name, url, username, password)
form := huh.NewForm(
huh.NewGroup(fields...).
Title("Kafka Connect"),
)
form.QuitAfterSubmit = false
form.Init()
return form
}
type ClusterDeleter func(name string) tea.Msg
func NewUpsertKcModel(
back ui.NavBack,
ktx *kontext.ProgramKtx,
deleter ClusterDeleter,
configs []config.KafkaConnectConfig,
connChecker kcadmin.ConnChecker,
cmdBar *cmdbar.NotifierCmdBar,
registerer tea.Cmd,
) *UpsertKcModel {
m := UpsertKcModel{}
m.back = back
m.ktx = ktx
m.connectClusters = configs
m.connChecker = connChecker
m.cmdBar = cmdBar
m.registerer = registerer
m.rows = m.createRows()
m.form = m.createKcForm()
m.table = ktable.NewDefaultTable()
if len(m.connectClusters) > 0 {
m.state = listing
} else {
m.state = entering
}
deleteMsgFunc := func(c string) string {
return c + " will be permanently deleted!"
}
deleteFunc := func(c string) tea.Cmd {
return func() tea.Msg {
return deleter(c)
}
}
m.deleteCmdbar = cmdbar.NewDeleteCmdBar[string](deleteMsgFunc, deleteFunc)
cmdbar.WithMsgHandler(m.cmdBar, func(msg kcadmin.ConnCheckStartedMsg, m *notifier.Model) (bool, tea.Cmd) {
return true, m.SpinWithLoadingMsg("Testing cluster connectivity")
})
cmdbar.WithMsgHandler(m.cmdBar, func(msg kcadmin.ConnCheckErrMsg, nm *notifier.Model) (bool, tea.Cmd) {
m.form = m.createKcForm()
m.state = entering
nm.ShowErrorMsg("Unable to reach the cluster", msg.Err)
return true, nm.AutoHideCmd(notifierCmdbarTag)
})
cmdbar.WithMsgHandler(m.cmdBar, func(msg kcadmin.ConnCheckSucceededMsg, m *notifier.Model) (bool, tea.Cmd) {
return true, m.SpinWithLoadingMsg("Connection succeeded, creating cluster")
})
return &m
}
package create_schema_page
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"ktea/kontext"
"ktea/sradmin"
"ktea/styles"
"ktea/ui"
"ktea/ui/components/cmdbar"
"ktea/ui/components/notifier"
"ktea/ui/components/statusbar"
"ktea/ui/pages/nav"
)
type state int
const (
entering state = 0
creating state = 1
)
type values struct {
subject string
schema string
}
type Model struct {
values
form *huh.Form
schemaCreator sradmin.SchemaCreator
cmdBar *cmdbar.NotifierCmdBar
state state
ktx *kontext.ProgramKtx
schemaInput *huh.Text
createdAtLeastOneSchema bool
}
func (m *Model) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
cmdbarView := m.cmdBar.View(ktx, renderer)
if m.form == nil {
m.form = newForm(m)
}
return ui.JoinVertical(
lipgloss.Top,
cmdbarView,
renderer.RenderWithStyle(m.form.View(), styles.Form),
)
}
func (m *Model) Update(msg tea.Msg) tea.Cmd {
var cmds []tea.Cmd
if m.form != nil {
form, cmd := m.form.Update(msg)
cmds = append(cmds, cmd)
if f, ok := form.(*huh.Form); ok {
m.form = f
}
if m.form.State == huh.StateCompleted && m.state == entering {
m.state = creating
return func() tea.Msg {
return m.schemaCreator.CreateSchema(sradmin.SubjectCreationDetails{
Subject: m.subject,
Schema: m.schema,
})
}
}
}
switch msg := msg.(type) {
case tea.KeyMsg:
{
switch msg.String() {
case "esc":
if m.state == creating {
return nil
}
cmds = append(cmds, ui.PublishMsg(nav.LoadSubjectsPageMsg{
Refresh: m.createdAtLeastOneSchema,
}))
default:
m.cmdBar.Notifier.Idle()
}
}
case sradmin.SchemaCreatedMsg:
m.createdAtLeastOneSchema = true
m.state = entering
m.form = nil
case sradmin.SchemaCreationErrMsg:
m.state = entering
m.form = nil
case sradmin.SchemaCreationStartedMsg:
m.state = creating
cmds = append(cmds, msg.AwaitCompletion)
}
_, _, cmd := m.cmdBar.Update(msg)
cmds = append(cmds, cmd)
return tea.Batch(cmds...)
}
func (m *Model) Shortcuts() []statusbar.Shortcut {
return []statusbar.Shortcut{
{"Confirm", "enter"},
{"Next Field", "tab"},
{"Prev. Field", "s-tab"},
{"Reset Form", "C-r"},
{"Go Back", "esc"},
}
}
func (m *Model) Title() string {
return "Subjects / Register New"
}
func newForm(model *Model) *huh.Form {
model.subject = ""
model.schema = ""
schemaInput := huh.NewText().
Value(&model.values.schema).
Title("Schema").
Validate(func(v string) error {
if v == "" {
return fmt.Errorf("schema cannot be empty")
}
return nil
}).
WithHeight(model.ktx.AvailableHeight - 9).(*huh.Text)
model.schemaInput = schemaInput
form := huh.NewForm(huh.NewGroup(
huh.NewInput().
Value(&model.values.subject).
Title("Subject").
Validate(func(v string) error {
if v == "" {
return fmt.Errorf("subject cannot be empty")
}
return nil
}),
schemaInput,
))
form.Init()
form.QuitAfterSubmit = false
return form
}
func New(schemaCreator sradmin.SchemaCreator, ktx *kontext.ProgramKtx) (*Model, tea.Cmd) {
model := &Model{}
model.ktx = ktx
model.schemaCreator = schemaCreator
model.state = entering
notifierCmdBar := cmdbar.NewNotifierCmdBar("create-schema-page")
cmdbar.WithMsgHandler(notifierCmdBar, func(msg sradmin.SchemaCreationStartedMsg, m *notifier.Model) (bool, tea.Cmd) {
cmd := m.SpinWithLoadingMsg("Creating Schema")
return true, cmd
})
cmdbar.WithMsgHandler(notifierCmdBar, func(msg sradmin.SchemaCreatedMsg, m *notifier.Model) (bool, tea.Cmd) {
m.ShowSuccessMsg("Schema created")
return true, nil
})
cmdbar.WithMsgHandler(notifierCmdBar, func(msg notifier.HideNotificationMsg, m *notifier.Model) (bool, tea.Cmd) {
m.Idle()
return true, nil
})
cmdbar.WithMsgHandler(notifierCmdBar, func(msg sradmin.SchemaCreationErrMsg, m *notifier.Model) (bool, tea.Cmd) {
m.ShowErrorMsg("Schema creation failed", msg.Err)
return true, nil
})
model.cmdBar = notifierCmdBar
return model, nil
}
package create_topic_page
import (
"errors"
"fmt"
bsp "github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"ktea/kadmin"
"ktea/kontext"
"ktea/styles"
"ktea/ui"
"ktea/ui/components/cmdbar"
"ktea/ui/components/notifier"
"ktea/ui/components/statusbar"
"ktea/ui/pages/nav"
"regexp"
"strconv"
"strings"
)
type formState int
const (
initial formState = 0
configEntered formState = 1
loading formState = 2
)
type Model struct {
shortcuts []statusbar.Shortcut
form *huh.Form
notifier *cmdbar.NotifierCmdBar
topicCreator kadmin.TopicCreator
formValues topicFormValues
formState formState
createdAtLeastOneTopic bool
}
type config struct {
key string
value string
}
type topicFormValues struct {
name string
numPartitions string
config string
configs []config
cleanupPolicy string
replicationFactor string
}
func (m *Model) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
var views []string
notifierView := m.notifier.View(ktx, renderer)
formView := renderer.RenderWithStyle(m.form.View(), styles.Form)
views = append(views, notifierView, formView)
if len(m.formValues.configs) > 0 {
views = append(views, renderer.Render("Custom Topic configurations:\n\n"))
for _, c := range m.formValues.configs {
views = append(views, renderer.Render(fmt.Sprintf("%s: %s\n", c.key, c.value)))
}
}
return ui.JoinVertical(lipgloss.Top, views...)
}
func (m *Model) Update(msg tea.Msg) tea.Cmd {
var cmds []tea.Cmd
_, _, cmd := m.notifier.Update(msg)
cmds = append(cmds, cmd)
switch msg := msg.(type) {
case kadmin.TopicCreationStartedMsg:
cmds = append(cmds, msg.AwaitCompletion)
return tea.Batch(cmds...)
case kadmin.TopicCreationErrMsg:
m.initForm(initial)
return tea.Batch(cmds...)
case bsp.TickMsg:
return tea.Batch(cmds...)
case tea.KeyMsg:
if msg.String() == "esc" && m.formState != loading {
return ui.PublishMsg(nav.LoadTopicsPageMsg{Refresh: m.createdAtLeastOneTopic})
} else if msg.String() == "ctrl+r" {
m.formValues.name = ""
m.formValues.cleanupPolicy = ""
m.formValues.config = ""
m.formValues.numPartitions = ""
m.formValues.replicationFactor = ""
m.formValues.configs = []config{}
m.initForm(initial)
return propagateMsgToForm(m, msg)
} else {
return propagateMsgToForm(m, msg)
}
case kadmin.TopicCreatedMsg:
m.formValues.name = ""
m.formValues.cleanupPolicy = ""
m.formValues.config = ""
m.formValues.numPartitions = ""
m.formValues.replicationFactor = ""
m.formValues.configs = []config{}
m.createdAtLeastOneTopic = true
m.initForm(initial)
return nil
default:
return propagateMsgToForm(m, msg)
}
}
func propagateMsgToForm(m *Model, msg tea.Msg) tea.Cmd {
var cmd tea.Cmd
form, c := m.form.Update(msg)
cmd = c
if f, ok := form.(*huh.Form); ok {
m.form = f
}
if m.form.State == huh.StateCompleted && m.formState != loading {
if m.formValues.config == "" {
m.formState = loading
return tea.Batch(
//m.notifier.SpinWithRocketMsg("Creating topic"),
func() tea.Msg {
numPartitions, _ := strconv.Atoi(m.formValues.numPartitions)
configs := map[string]string{
"cleanup.policy": m.formValues.cleanupPolicy,
}
for _, c := range m.formValues.configs {
configs[c.key] = c.value
}
replicationFactor, _ := strconv.Atoi(m.formValues.replicationFactor)
return m.topicCreator.CreateTopic(
kadmin.TopicCreationDetails{
Name: m.formValues.name,
NumPartitions: numPartitions,
Properties: configs,
ReplicationFactor: int16(replicationFactor),
})
})
} else {
m.formState = configEntered
split := strings.Split(m.formValues.config, "=")
m.formValues.configs = append(m.formValues.configs, config{split[0], split[1]})
m.formValues.config = ""
m.initForm(configEntered)
return cmd
}
}
return cmd
}
func (m *Model) Shortcuts() []statusbar.Shortcut {
return m.shortcuts
}
func (m *Model) Title() string {
return "Topics / Create"
}
func (m *Model) initForm(fs formState) {
topicNameInput := huh.NewInput().
Title("Topic name").
Value(&m.formValues.name).
Validate(func(str string) error {
if str == "" {
return errors.New("topic Name cannot be empty")
}
return nil
})
numPartField := huh.NewInput().
Title("Number of Partitions").
Value(&m.formValues.numPartitions).
Validate(func(str string) error {
if str == "" {
return errors.New("number of Partitions cannot be empty")
}
if n, e := strconv.Atoi(str); e != nil {
return errors.New(fmt.Sprintf("'%s' is not a valid numeric partition count value", str))
} else if n <= 0 {
return errors.New("value must be greater than zero")
}
return nil
})
replicationFactorField := huh.NewInput().
Title("Replication Factor").
Validate(func(r string) error {
if r == "" {
return errors.New("replication factory cannot be empty")
}
if n, e := strconv.Atoi(r); e != nil {
return errors.New(fmt.Sprintf("'%s' is not a valid numeric replication factor value", r))
} else if n <= 0 {
return errors.New("value must be greater than zero")
}
return nil
}).
Value(&m.formValues.replicationFactor)
cleanupPolicySelect := huh.NewSelect[string]().
Title("Cleanup Policy").
Value(&m.formValues.cleanupPolicy).
Options(
huh.NewOption("Retention (delete)", "delete"),
huh.NewOption("Compaction (compact)", "compact"),
huh.NewOption("Retention + Compaction", "delete-compact"))
configInput := huh.NewInput().
Description("Enter custom topic configurations in the format config=value. Leave blank to create the topic.").
Title("Config").
Key("configKey").
Suggestions([]string{
"cleanup.policy",
"compression.type",
"delete.retention.ms",
"file.delete.delay.ms",
"flush.messages",
"flush.ms",
"follower.replication.throttled.replicas",
"index.interval.bytes",
"leader.replication.throttled.replicas",
"local.retention.bytes",
"local.retention.ms",
"max.compaction.lag.ms",
"max.message.bytes",
"message.format.version",
"message.timestamp.difference.max.ms",
"message.timestamp.type",
"min.cleanable.dirty.ratio",
"min.compaction.lag.ms",
"min.insync.replicas",
"preallocate",
"remote.storage.enable",
"retention.bytes",
"retention.ms",
"segment.bytes",
"segment.index.bytes",
"segment.jitter.ms",
"segment.ms",
"unclean.leader.election.enable",
"message.downconversion.enable",
}).
Validate(func(str string) error {
if str == "" {
return nil
}
r, _ := regexp.Compile(`^[\w.]+=\w+$`)
if r.MatchString(str) {
return nil
}
return errors.New("please enter configurations in the format \"config=value\"")
}).
Value(&m.formValues.config)
form := huh.NewForm(huh.NewGroup(
topicNameInput,
numPartField,
replicationFactorField,
cleanupPolicySelect,
configInput,
))
form.QuitAfterSubmit = false
if m.formState == configEntered {
form.NextField()
form.NextField()
form.NextField()
form.NextField()
}
form.Init()
m.formState = fs
m.form = form
}
func New(tc kadmin.TopicCreator) *Model {
var t = Model{}
t.topicCreator = tc
t.shortcuts = []statusbar.Shortcut{
{"Confirm", "enter"},
{"Next Field", "tab"},
{"Prev. Field", "s-tab"},
{"Reset Form", "C-r"},
{"Go Back", "esc"},
}
t.initForm(initial)
notifierCmdBar := cmdbar.NewNotifierCmdBar("create-topic-page")
cmdbar.WithMsgHandler(notifierCmdBar, func(msg kadmin.TopicCreationStartedMsg, m *notifier.Model) (bool, tea.Cmd) {
cmd := m.SpinWithLoadingMsg("Creating Topic")
return true, cmd
})
cmdbar.WithMsgHandler(notifierCmdBar, func(msg kadmin.TopicCreationErrMsg, m *notifier.Model) (bool, tea.Cmd) {
m.ShowErrorMsg("Failed to create Topic", msg.Err)
return true, nil
})
cmdbar.WithMsgHandler(notifierCmdBar, func(msg kadmin.TopicCreatedMsg, m *notifier.Model) (bool, tea.Cmd) {
m.ShowSuccessMsg("Topic created!")
return true, m.AutoHideCmd("create-topic-page")
})
t.notifier = notifierCmdBar
return &t
}
package kcon_clusters_page
import (
"fmt"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/log"
"ktea/config"
"ktea/kontext"
"ktea/ui"
"ktea/ui/components/border"
"ktea/ui/components/statusbar"
ktable "ktea/ui/components/table"
"ktea/ui/pages/kcon_page"
"reflect"
)
type Model struct {
table table.Model
loadKConPage kcon_page.LoadPage
cluster *config.Cluster
}
func (m *Model) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
var rows []table.Row
for _, cluster := range ktx.Config.ActiveCluster().KafkaConnectClusters {
rows = append(rows, table.Row{cluster.Name})
}
m.table.SetWidth(ktx.WindowWidth - 2)
m.table.SetColumns([]table.Column{
{"Name", ktx.WindowWidth - 4},
})
m.table.SetRows(rows)
m.table.SetHeight(ktx.AvailableHeight - 2)
b := border.New(border.WithTitleFn(func() string {
return border.KeyValueTitle(
"Total Kafka Connect Clusters",
fmt.Sprintf("%d/%d", len(rows), len(m.cluster.KafkaConnectClusters)),
true,
)
}))
return renderer.Render(b.View(m.table.View()))
}
func (m *Model) Update(msg tea.Msg) tea.Cmd {
log.Debug("Received Update", "msg", reflect.TypeOf(msg))
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyEnter:
var name = m.table.SelectedRow()[0]
for _, cluster := range m.cluster.KafkaConnectClusters {
if cluster.Name == name {
return m.loadKConPage(cluster)
}
}
panic("Kafka Connect cluster " + name + " not found in active Kafka Cluster config.")
}
}
t, c := m.table.Update(msg)
m.table = t
return c
}
func (m *Model) Shortcuts() []statusbar.Shortcut {
return []statusbar.Shortcut{
{"View", "enter"},
}
}
func (m *Model) Title() string {
return "Kafka Connect Clusters"
}
func New(cluster *config.Cluster, loadKConPage kcon_page.LoadPage) (*Model, tea.Cmd) {
m := Model{
loadKConPage: loadKConPage,
cluster: cluster,
}
m.table = ktable.NewDefaultTable()
return &m, nil
}
package kcon_page
import (
"fmt"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/log"
"ktea/config"
"ktea/kcadmin"
"ktea/kontext"
"ktea/styles"
"ktea/ui"
"ktea/ui/components/border"
"ktea/ui/components/cmdbar"
"ktea/ui/components/notifier"
"ktea/ui/components/statusbar"
ktable "ktea/ui/components/table"
"reflect"
"sort"
"strconv"
"strings"
"time"
)
type Model struct {
border *border.Model
cmdBar *cmdbar.TableCmdsBar[string]
table *table.Model
stsSpinner *spinner.Model
sort cmdbar.SortLabel
connectors *kcadmin.Connectors
rows []table.Row
sortByCmdBar *cmdbar.SortByCmdBar
kca kcadmin.Admin
navBack ui.NavBack
connectorName string
state
stateChangingConnectorName string
resumeDeadline *time.Time
connectorChangeState string
}
type state int
type LoadPage func(c config.KafkaConnectConfig) tea.Cmd
const (
loading state = iota
loaded
errored
)
func (m *Model) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
cmdBarView := m.cmdBar.View(ktx, renderer)
m.rows = m.createRows()
available := ktx.WindowWidth - 8
nameCol := int(float64(available) * 0.8)
tasksCol := int(float64(available) * 0.08)
compCol := available - nameCol - tasksCol
m.table.SetColumns([]table.Column{
{m.sortByCmdBar.PrefixSortIcon("Name"), nameCol},
{m.sortByCmdBar.PrefixSortIcon("Tasks"), tasksCol},
{m.sortByCmdBar.PrefixSortIcon("Status"), compCol},
})
m.table.SetRows(m.rows)
m.table.SetWidth(ktx.WindowWidth - 2)
m.table.SetHeight(ktx.AvailableHeight - 2)
tableView := m.border.View(m.table.View())
return ui.JoinVertical(lipgloss.Top, cmdBarView, tableView)
}
type ConnectorStateAlreadyChanging struct {
}
func (m *Model) Update(msg tea.Msg) tea.Cmd {
log.Debug("Received Update", "msg", reflect.TypeOf(msg))
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "esc":
if !m.cmdBar.IsFocussed() {
return m.navBack()
}
case "P":
if m.stateChangingConnectorName == "" {
var name = m.selectedConnector()
log.Debug("Pausing", "connector name", name)
m.connectorChangeState = "PAUSING"
return func() tea.Msg {
return m.kca.Pause(name)
}
} else {
return func() tea.Msg {
return ConnectorStateAlreadyChanging{}
}
}
case "R":
if m.stateChangingConnectorName == "" {
var name = m.selectedConnector()
m.connectorChangeState = "RESUMING"
log.Debug("Resuming", "connector name", name)
return func() tea.Msg {
return m.kca.Resume(name)
}
} else {
return func() tea.Msg {
return ConnectorStateAlreadyChanging{}
}
}
case "f5":
if m.state == loading {
log.Debug("Not refreshing connectors due to loading state")
return nil
}
m.connectors = nil
m.rows = m.createRows()
m.state = loading
log.Debug("Refreshing connectors")
return m.kca.ListActiveConnectors
}
case spinner.TickMsg:
if m.stsSpinner != nil {
sm, cmd := m.stsSpinner.Update(msg)
m.stsSpinner = &sm
cmds = append(cmds, cmd)
}
case kcadmin.ResumingStartedMsg:
var name = m.selectedConnector()
cmd := m.newSpinner(name)
return tea.Batch(cmd, msg.AwaitCompletion)
case kcadmin.ResumeRequestedMsg:
return func() tea.Msg {
return m.waitForConnectorState("RUNNING")
}
case kcadmin.PausingStartedMsg:
var name = m.selectedConnector()
cmd := m.newSpinner(name)
return tea.Batch(cmd, msg.AwaitCompletion)
case kcadmin.PauseRequestedMsg:
return func() tea.Msg {
return m.waitForConnectorState("PAUSED")
}
case ConnectorStateChanged:
m.stsSpinner = nil
m.connectors = msg.Connectors
m.stateChangingConnectorName = ""
case kcadmin.ConnectorListingStartedMsg:
cmds = append(cmds, msg.AwaitCompletion)
case kcadmin.ConnectorsListedMsg:
m.connectors = &msg.Connectors
m.state = loaded
case kcadmin.ConnectorDeletionStartedMsg:
m.state = loading
cmds = append(cmds, msg.AwaitCompletion)
case kcadmin.ConnectorDeletedMsg:
m.state = loaded
delete(*m.connectors, msg.Name)
}
var (
selection = m.selectedConnector()
cmd tea.Cmd
)
_, cmd = m.cmdBar.Update(msg, &selection)
m.border.Focused = !m.cmdBar.IsFocussed()
if cmd != nil {
cmds = append(cmds, cmd)
}
// make sure table navigation is off when the cmdbar is focussed
if !m.cmdBar.IsFocussed() {
t, cmd := m.table.Update(msg)
m.table = &t
cmds = append(cmds, cmd)
}
m.rows = m.createRows()
return tea.Batch(cmds...)
}
func (m *Model) newSpinner(name string) tea.Cmd {
model := spinner.New()
m.stsSpinner = &model
m.stsSpinner.Spinner = spinner.Dot
m.stateChangingConnectorName = name
return m.stsSpinner.Tick
}
type ConnectorStateChanged struct {
Connectors *kcadmin.Connectors
}
func (m *Model) waitForConnectorState(state string) tea.Msg {
if m.resumeDeadline == nil {
resumeDeadline := time.Now().Add(30 * time.Second)
m.resumeDeadline = &resumeDeadline
}
if time.Now().After(*m.resumeDeadline) {
log.Warn("Timeout waiting for connector to resume", "connector", m.stateChangingConnectorName)
m.resumeDeadline = nil
return kcadmin.ConnectorListingErrMsg{
Err: fmt.Errorf("timeout waiting for connector %s to resume", m.stateChangingConnectorName),
}
}
msg := m.kca.ListActiveConnectors()
startedMsg, ok := msg.(kcadmin.ConnectorListingStartedMsg)
if !ok {
log.Error("Expected ConnectorListingStartedMsg but got something else", "msg", msg)
return m.waitForConnectorState
}
completedMsg := startedMsg.AwaitCompletion()
listedMsg, ok := completedMsg.(kcadmin.ConnectorsListedMsg)
if !ok {
log.Error("Expected ConnectorsListedMsg but got something else", "msg", completedMsg)
return m.waitForConnectorState
}
res := listedMsg
log.Error(res.Connectors)
if res.Connectors[m.stateChangingConnectorName].Status.Connector.State != state {
return m.waitForConnectorState(state)
}
return ConnectorStateChanged{
Connectors: &res.Connectors,
}
}
func (m *Model) selectedConnector() string {
selectedRow := m.table.SelectedRow()
var selectedConnector string
if selectedRow != nil {
selectedConnector = selectedRow[0]
}
return selectedConnector
}
func (m *Model) Shortcuts() []statusbar.Shortcut {
if m.cmdBar.IsFocussed() {
shortCuts := m.cmdBar.Shortcuts()
if shortCuts != nil {
return shortCuts
}
}
return []statusbar.Shortcut{
{"Search", "/"},
{"Delete", "F2"},
{"Sort", "F3"},
{"Refresh", "F5"},
{"Pause", "S-p"},
{"Resume", "S-r"},
}
}
func (m *Model) Title() string {
return "Kafka Connect Clusters / " + m.connectorName
}
func (m *Model) createRows() []table.Row {
var rows []table.Row
if m.connectors != nil {
for k, c := range *m.connectors {
if m.cmdBar.GetSearchTerm() != "" {
if strings.Contains(strings.ToLower(k), strings.ToLower(m.cmdBar.GetSearchTerm())) {
rows = append(rows, table.Row{k, strconv.Itoa(len(c.Status.Tasks)), c.Status.Connector.State})
}
} else {
status := c.Status.Connector.State
if m.stateChangingConnectorName == k {
status = m.stsSpinner.View() + fmt.Sprintf(" %s ", m.connectorChangeState) + m.stsSpinner.View()
}
rows = append(rows, table.Row{k, strconv.Itoa(len(c.Status.Tasks)), status})
}
}
}
sort.SliceStable(rows, func(i, j int) bool {
switch m.sortByCmdBar.SortedBy().Label {
case "Name":
if m.sortByCmdBar.SortedBy().Direction == cmdbar.Asc {
return rows[i][0] < rows[j][0]
}
return rows[i][0] > rows[j][0]
default:
panic(fmt.Sprintf("unexpected sort label: %s", m.sortByCmdBar.SortedBy().Label))
}
})
return rows
}
func New(
navBack ui.NavBack,
kca kcadmin.Admin,
connectorName string,
) (*Model, tea.Cmd) {
m := Model{}
m.connectorName = connectorName
m.navBack = navBack
m.kca = kca
m.state = loading
m.border = border.New(
border.WithTitleFn(func() string {
if m.connectors == nil {
return border.Title("Loading Connectors", false)
}
return border.KeyValueTitle(
"Total Connectors",
fmt.Sprintf("%d/%d", len(m.rows), len(*m.connectors)),
true,
)
}))
m.border.Focused = false
t := ktable.NewDefaultTable()
m.table = &t
sortByCmdBar := cmdbar.NewSortByCmdBar(
[]cmdbar.SortLabel{
{
Label: "Name",
Direction: cmdbar.Asc,
},
},
cmdbar.WithSortSelectedCallback(func(label cmdbar.SortLabel) {
m.sort = label
}),
)
m.sortByCmdBar = sortByCmdBar
notifierCmdBar := cmdbar.NewNotifierCmdBar("kcons-page")
cmdbar.WithMsgHandler(
notifierCmdBar,
func(
msg kcadmin.ConnectorListingStartedMsg,
m *notifier.Model,
) (bool, tea.Cmd) {
cmd := m.SpinWithLoadingMsg("Loading connectors")
return true, cmd
},
)
cmdbar.WithMsgHandler(
notifierCmdBar,
func(
msg kcadmin.ConnectorsListedMsg,
m *notifier.Model,
) (bool, tea.Cmd) {
m.Idle()
return true, nil
},
)
cmdbar.WithMsgHandler(
notifierCmdBar,
func(
msg kcadmin.ConnectorListingErrMsg,
model *notifier.Model,
) (bool, tea.Cmd) {
cmd := model.ShowError(fmt.Errorf("unable to reach connect cluster: %v", msg.Err))
m.state = errored
return true, cmd
},
)
cmdbar.WithMsgHandler(
notifierCmdBar,
func(
msg kcadmin.ConnectorDeletionStartedMsg,
m *notifier.Model,
) (bool, tea.Cmd) {
cmd := m.SpinWithLoadingMsg("Deleting Connector")
return true, cmd
},
)
cmdbar.WithMsgHandler(
notifierCmdBar,
func(
msg kcadmin.ConnectorDeletedMsg,
m *notifier.Model,
) (bool, tea.Cmd) {
cmd := m.ShowSuccessMsg("Connector deleted")
return true, cmd
},
)
cmdbar.WithMsgHandler(
notifierCmdBar,
func(
msg kcadmin.ConnectorDeletionErrMsg,
m *notifier.Model,
) (bool, tea.Cmd) {
cmd := m.ShowError(msg.Err)
return true, cmd
},
)
cmdbar.WithMsgHandler(
notifierCmdBar,
func(
msg ui.RegainedFocusMsg,
model *notifier.Model,
) (bool, tea.Cmd) {
if model.State == notifier.Spinning {
cmd := model.SpinWithLoadingMsg("Loading connectors")
return true, cmd
}
if model.State == notifier.Err {
return true, nil
}
return false, nil
},
)
cmdbar.WithMsgHandler(
notifierCmdBar,
func(
msg kcadmin.PauseRequestedMsg,
m *notifier.Model,
) (bool, tea.Cmd) {
m.Idle()
return true, nil
},
)
cmdbar.WithMsgHandler(
notifierCmdBar,
func(
msg ConnectorStateAlreadyChanging,
m *notifier.Model,
) (bool, tea.Cmd) {
m.ShowError(fmt.Errorf("Other connector already in progress of changing state, wait until it completes."))
return true, m.AutoHideCmd("kcons-page")
},
)
deleteMsgFunc := func(connector string) string {
message := connector + lipgloss.NewStyle().
Foreground(lipgloss.Color(styles.ColorIndigo)).
Bold(true).
Render(" will be deleted permanently")
return message
}
deleteFunc := func(name string) tea.Cmd {
return func() tea.Msg {
return kca.DeleteConnector(name)
}
}
m.cmdBar = cmdbar.NewTableCmdsBar(
cmdbar.NewDeleteCmdBar[string](deleteMsgFunc, deleteFunc),
cmdbar.NewSearchCmdBar("Search connectors by name"),
notifierCmdBar,
sortByCmdBar)
m.sort = sortByCmdBar.SortedBy()
return &m, kca.ListActiveConnectors
}
package publish_page
import (
"errors"
"fmt"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/log"
"ktea/kadmin"
"ktea/kontext"
"ktea/styles"
"ktea/ui"
"ktea/ui/components/cmdbar"
"ktea/ui/components/notifier"
"ktea/ui/components/statusbar"
"ktea/ui/pages/nav"
"reflect"
"strconv"
"strings"
"time"
)
type state int
const (
none = 0
publishing = 1
)
type Model struct {
state state
topicForm *huh.Form
publisher kadmin.Publisher
topic *kadmin.ListedTopic
notifier *notifier.Model
formValues *formValues
}
type LoadPageMsg struct {
Topic kadmin.ListedTopic
}
type formValues struct {
Key string
Partition string
Payload string
Headers string
}
func (m *Model) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
notifierView := m.notifier.View(ktx, renderer)
if notifierView != "" {
notifierView = styles.CmdBarWithWidth(ktx.WindowWidth - cmdbar.BorderedPadding).Render(notifierView)
}
if m.topicForm == nil {
m.topicForm = m.newForm(ktx)
}
return ui.JoinVertical(lipgloss.Top,
notifierView,
renderer.RenderWithStyle(m.topicForm.View(), styles.Form),
)
}
func (m *Model) Update(msg tea.Msg) tea.Cmd {
log.Debug("Received Update", "msg", reflect.TypeOf(msg))
switch msg := msg.(type) {
case spinner.TickMsg, notifier.HideNotificationMsg:
return m.notifier.Update(msg)
case kadmin.PublicationStartedMsg:
return tea.Batch(
m.notifier.SpinWithLoadingMsg("Publishing record"),
msg.AwaitCompletion,
)
case kadmin.PublicationFailed:
m.state = none
m.topicForm.Init()
return m.notifier.ShowErrorMsg("Publication failed!", fmt.Errorf("TODO"))
case kadmin.PublicationSucceeded:
m.resetForm()
return tea.Batch(
m.notifier.ShowSuccessMsg("Record published!"),
func() tea.Msg {
time.Sleep(5 * time.Second)
return notifier.HideNotificationMsg{}
})
case tea.KeyMsg:
switch msg.Type {
case tea.KeyEsc:
if m.state == publishing {
return nil
}
return ui.PublishMsg(nav.LoadTopicsPageMsg{})
case tea.KeyCtrlR:
m.resetForm()
}
}
if m.topicForm != nil && m.state != publishing {
form, cmd := m.topicForm.Update(msg)
if f, ok := form.(*huh.Form); ok {
m.topicForm = f
}
if m.topicForm != nil && m.topicForm.State == huh.StateCompleted {
m.state = publishing
m.topicForm.State = huh.StateNormal
return tea.Batch(
m.notifier.SpinWithRocketMsg("Publishing record"),
func() tea.Msg {
var part *int
if m.formValues.Partition != "" {
if p, err := strconv.Atoi(m.formValues.Partition); err == nil {
part = &p
}
}
return m.publisher.PublishRecord(&kadmin.ProducerRecord{
Key: m.formValues.Key,
Value: []byte(m.formValues.Payload),
Topic: m.topic.Name,
Headers: m.formValues.parsedHeaders(),
Partition: part,
})
})
}
return cmd
}
return nil
}
func (v *formValues) parsedHeaders() map[string]string {
if v.Headers == "" {
return map[string]string{}
}
headers := map[string]string{}
for _, line := range strings.Split(v.Headers, "\n") {
if strings.Contains(line, "=") {
split := strings.Split(line, "=")
key := split[0]
value := split[1]
headers[key] = value
}
}
return headers
}
func (m *Model) Shortcuts() []statusbar.Shortcut {
return []statusbar.Shortcut{
{"Confirm", "enter"},
{"Reset Form", "C-r"},
{"Go Back", "esc"},
}
}
func (m *Model) Title() string {
return "Topics / " + m.topic.Name + " / Produce"
}
func (m *Model) resetForm() {
m.state = none
m.formValues.Key = ""
m.formValues.Partition = ""
m.formValues.Payload = ""
m.formValues.Headers = ""
m.topicForm = nil
}
func (m *Model) newForm(ktx *kontext.ProgramKtx) *huh.Form {
payload := huh.NewText().
ShowLineNumbers(true).
Value(&m.formValues.Payload).
Title("Payload").
WithHeight(ktx.AvailableHeight - 10)
key := huh.NewInput().
Title("Key").
Description("Leave empty to use a null key for the message.").
Value(&m.formValues.Key)
partition := huh.NewInput().
Value(&m.formValues.Partition).
Description("Leave empty to use murmur2 key based partitioner (identical to JVM clients).").
Title("Partition").
Validate(func(str string) error {
if str == "" {
return nil
}
if n, e := strconv.Atoi(str); e != nil {
return errors.New(fmt.Sprintf("'%s' is not a valid numeric partition value", str))
} else if n < 0 {
return errors.New("value must be at least zero")
} else if n > m.topic.PartitionCount-1 {
return errors.New(fmt.Sprintf("partition index %s is invalid, valid range is 0-%d", str, m.topic.PartitionCount-1))
}
return nil
})
headers := huh.NewText().
Description("Enter headers in the format key=value, one per line.").
ShowLineNumbers(true).
Value(&m.formValues.Headers).
Title("Headers").
WithHeight(10)
form := huh.NewForm(
huh.NewGroup(
key,
partition,
headers,
).WithWidth(ktx.WindowWidth/2),
huh.NewGroup(
payload,
),
huh.NewGroup(huh.NewConfirm().
Inline(true).
Affirmative("Produce").
Negative(""),
),
)
form.WithLayout(huh.LayoutGrid(4, 2))
form.QuitAfterSubmit = false
form.Init()
return form
}
func New(p kadmin.Publisher, topic *kadmin.ListedTopic) *Model {
return &Model{
topic: topic,
publisher: p,
notifier: notifier.New(),
formValues: &formValues{},
}
}
package record_details_page
import (
"fmt"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"ktea/config"
"ktea/kadmin"
"ktea/kontext"
"ktea/styles"
"ktea/ui"
"ktea/ui/clipper"
"ktea/ui/components/border"
"ktea/ui/components/cmdbar"
"ktea/ui/components/notifier"
"ktea/ui/components/statusbar"
ktable "ktea/ui/components/table"
"ktea/ui/pages/nav"
"sort"
"strconv"
"strings"
"time"
)
type focus bool
type state bool
const (
mainViewFocus focus = true
headersViewFocus focus = false
recordView state = true
schemaView state = false
)
type Model struct {
notifierCmdbar *cmdbar.NotifierCmdBar
record *kadmin.ConsumerRecord
recordVp *viewport.Model
headerValueVp *viewport.Model
topicName string
headerKeyTable *table.Model
headerRows []table.Row
focus focus
state state
payload string
err error
metaInfo string
clipWriter clipper.Writer
config *config.Config
schemaVp *viewport.Model
border *border.Model
}
type PayloadCopiedMsg struct {
}
type SchemaCopiedMsg struct {
}
type HeaderValueCopiedMsg struct {
}
type CopyErrorMsg struct {
Err error
}
func (m *Model) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
notifierCmdbarView := m.notifierCmdbar.View(ktx, renderer)
width := int(float64(ktx.WindowWidth) * 0.70)
height := ktx.AvailableHeight - 2
mainView := m.mainView(width, height)
sidebarView := m.sidebarView(ktx, width, height)
return ui.JoinVertical(
lipgloss.Top,
notifierCmdbarView,
lipgloss.NewStyle().Render(lipgloss.JoinHorizontal(
lipgloss.Top,
m.border.View(mainView),
sidebarView)))
}
func (m *Model) Update(msg tea.Msg) tea.Cmd {
if m.recordVp == nil && m.err == nil {
return nil
}
var cmds []tea.Cmd
_, _, cmd := m.notifierCmdbar.Update(msg)
cmds = append(cmds, cmd)
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "esc":
return ui.PublishMsg(nav.LoadCachedConsumptionPageMsg{})
case "h", "left", "right":
if len(m.record.Headers) >= 1 {
m.focus = !m.focus
m.border.Focused = m.focus == mainViewFocus
}
case "c":
cmds = m.handleCopy(cmds)
case "tab":
if m.record.Payload.Schema != "" && m.focus == mainViewFocus {
m.state = !m.state
m.border.NextTab()
}
default:
cmds = m.updatedFocussedArea(msg, cmds)
}
}
return tea.Batch(cmds...)
}
func (m *Model) mainView(width int, height int) string {
var mainView string
if m.state == recordView {
mainView = m.recordView(width, height)
} else {
mainView = m.schemaView(width, height)
}
return mainView
}
func (m *Model) schemaView(width int, height int) string {
if m.schemaVp == nil {
schemaVp := viewport.New(width, height)
m.schemaVp = &schemaVp
if m.err == nil {
m.schemaVp.SetContent(lipgloss.NewStyle().
Padding(0, 1).
Render(ui.PrettyPrintJson(m.record.Payload.Schema)))
}
} else {
m.schemaVp.Height = height
m.schemaVp.Width = width
}
return m.schemaVp.View()
}
func (m *Model) sidebarView(ktx *kontext.ProgramKtx, payloadWidth int, height int) string {
headersTableStyle := m.headerStyle()
sideBarWidth := ktx.WindowWidth - (payloadWidth + 7)
var headerSideBar string
if len(m.record.Headers) == 0 {
headerSideBar = ui.JoinVertical(
lipgloss.Top,
lipgloss.NewStyle().Padding(1).Render(m.metaInfo),
lipgloss.JoinVertical(lipgloss.Center, lipgloss.NewStyle().Padding(1).Render("No headers present")),
)
} else {
headerValueTableHeight := len(m.record.Headers) + 4
headerValueVp := viewport.New(sideBarWidth, height-headerValueTableHeight-4)
m.headerValueVp = &headerValueVp
m.headerKeyTable.SetColumns([]table.Column{
{"Header Key", sideBarWidth},
})
m.headerKeyTable.SetHeight(headerValueTableHeight)
m.headerKeyTable.SetRows(m.headerRows)
headerValueLine := strings.Builder{}
for i := 0; i < sideBarWidth; i++ {
headerValueLine.WriteString("─")
}
headerValue := m.selectedHeaderValue()
m.headerValueVp.SetContent("Header Value\n" + headerValueLine.String() + "\n" + headerValue)
headerSideBar = ui.JoinVertical(
lipgloss.Top,
lipgloss.NewStyle().Padding(1).Render(m.metaInfo),
headersTableStyle.Render(lipgloss.JoinVertical(lipgloss.Top, m.headerKeyTable.View(), m.headerValueVp.View())),
)
}
return headerSideBar
}
func (m *Model) selectedHeaderValue() string {
selectedRow := m.headerKeyTable.SelectedRow()
if selectedRow == nil {
if len(m.record.Headers) > 0 {
return m.record.Headers[0].Value.String()
}
} else {
return m.record.Headers[m.headerKeyTable.Cursor()].Value.String()
}
return ""
}
func (m *Model) recordView(payloadWidth int, height int) string {
if m.recordVp == nil {
recordVp := viewport.New(payloadWidth, height)
m.recordVp = &recordVp
if m.err == nil {
m.recordVp.SetContent(lipgloss.NewStyle().
Padding(0, 1).
Render(m.payload))
} else {
m.recordVp.SetContent(lipgloss.NewStyle().
AlignHorizontal(lipgloss.Center).
AlignVertical(lipgloss.Center).
Width(payloadWidth).
Height(height).
Render(lipgloss.NewStyle().
Bold(true).
Padding(1).
Foreground(lipgloss.Color(styles.ColorGrey)).
Render("Unable to render payload")))
}
} else {
m.recordVp.Height = height
m.recordVp.Width = payloadWidth
}
return m.recordVp.View()
}
func (m *Model) headerStyle() lipgloss.Style {
var headersTableStyle lipgloss.Style
if m.focus == mainViewFocus {
headersTableStyle = lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
Padding(0).
Margin(0).
BorderForeground(lipgloss.Color(styles.ColorBlurBorder))
} else {
headersTableStyle = lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
Padding(0).
Margin(0).
BorderForeground(lipgloss.Color(styles.ColorFocusBorder))
}
return headersTableStyle
}
func (m *Model) handleCopy(cmds []tea.Cmd) []tea.Cmd {
if m.focus == mainViewFocus {
var copiedValue string
if m.state == schemaView {
copiedValue = ansi.Strip(m.record.Payload.Schema)
} else {
copiedValue = ansi.Strip(m.payload)
}
err := m.clipWriter.Write(copiedValue)
if err != nil {
cmds = append(cmds, ui.PublishMsg(CopyErrorMsg{Err: err}))
} else if m.state == recordView {
cmds = append(cmds, ui.PublishMsg(PayloadCopiedMsg{}))
} else {
cmds = append(cmds, ui.PublishMsg(SchemaCopiedMsg{}))
}
} else {
err := m.clipWriter.Write(m.selectedHeaderValue())
if err != nil {
cmds = append(cmds, ui.PublishMsg(CopyErrorMsg{Err: err}))
} else {
cmds = append(cmds, ui.PublishMsg(HeaderValueCopiedMsg{}))
}
}
return cmds
}
func (m *Model) updatedFocussedArea(msg tea.Msg, cmds []tea.Cmd) []tea.Cmd {
// only update component if no error is present
if m.err != nil {
return cmds
}
if m.focus == mainViewFocus {
if m.state == recordView {
vp, cmd := m.recordVp.Update(msg)
cmds = append(cmds, cmd)
m.recordVp = &vp
} else {
vp, cmd := m.schemaVp.Update(msg)
cmds = append(cmds, cmd)
m.schemaVp = &vp
}
} else {
t, cmd := m.headerKeyTable.Update(msg)
cmds = append(cmds, cmd)
m.headerKeyTable = &t
}
return cmds
}
func (m *Model) Shortcuts() []statusbar.Shortcut {
whatToCopy := "Header Value"
if m.focus == mainViewFocus {
if m.state == schemaView {
whatToCopy = "Schema"
} else {
whatToCopy = "Record"
}
}
if m.err == nil {
shortcuts := []statusbar.Shortcut{
{"Toggle Headers/Content", "h/left/right"},
{"Go Back", "esc"},
{"Copy " + whatToCopy, "c"},
}
if m.config.ActiveCluster().HasSchemaRegistry() && m.focus == mainViewFocus {
shortcuts = append(shortcuts, statusbar.Shortcut{
Name: "Toggle Record/Schema",
Keybinding: "<tab>",
})
}
return shortcuts
} else {
return []statusbar.Shortcut{
{"Go Back", "esc"},
}
}
}
func (m *Model) Title() string {
return "Topics / " + m.topicName + " / Records / " + strconv.FormatInt(m.record.Offset, 10)
}
func New(
record *kadmin.ConsumerRecord,
topicName string,
clipWriter clipper.Writer,
ktx *kontext.ProgramKtx,
) *Model {
headersTable := ktable.NewDefaultTable()
var headerRows []table.Row
sort.SliceStable(record.Headers, func(i, j int) bool {
return record.Headers[i].Key < record.Headers[j].Key
})
for _, header := range record.Headers {
headerRows = append(headerRows, table.Row{header.Key})
}
notifierCmdBar := cmdbar.NewNotifierCmdBar("record-details-page")
var (
payload string
err error
)
if record.Err == nil {
payload = ui.PrettyPrintJson(record.Payload.Value)
} else {
err = record.Err
notifierCmdBar.Notifier.ShowError(record.Err)
}
key := record.Key
if key == "" {
key = "<null>"
}
metaInfo := fmt.Sprintf("key: %s\ntimestamp: %s", key, record.Timestamp.Format(time.UnixDate))
cmdbar.WithMsgHandler(notifierCmdBar, func(msg PayloadCopiedMsg, m *notifier.Model) (bool, tea.Cmd) {
m.ShowSuccessMsg("Payload copied")
return true, m.AutoHideCmd("record-details-page")
})
cmdbar.WithMsgHandler(notifierCmdBar, func(msg SchemaCopiedMsg, m *notifier.Model) (bool, tea.Cmd) {
m.ShowSuccessMsg("Schema copied")
return true, m.AutoHideCmd("record-details-page")
})
cmdbar.WithMsgHandler(notifierCmdBar, func(msg HeaderValueCopiedMsg, m *notifier.Model) (bool, tea.Cmd) {
m.ShowSuccessMsg("Header Value copied")
return true, m.AutoHideCmd("record-details-page")
})
cmdbar.WithMsgHandler(notifierCmdBar, func(msg CopyErrorMsg, m *notifier.Model) (bool, tea.Cmd) {
m.ShowErrorMsg("Copy failed", msg.Err)
return true, m.AutoHideCmd("record-details-page")
})
var tabs []border.Tab
if record.Payload.Schema != "" {
tabs = []border.Tab{
{Title: "Record", TabLabel: "record"},
{Title: "Schema", TabLabel: "record"},
}
}
b := border.New(
border.WithTabs(tabs...),
border.WithTitle("AVRO Record"))
return &Model{
record: record,
topicName: topicName,
headerKeyTable: &headersTable,
focus: mainViewFocus,
headerRows: headerRows,
payload: payload,
err: err,
metaInfo: metaInfo,
clipWriter: clipWriter,
notifierCmdbar: notifierCmdBar,
config: ktx.Config,
state: recordView,
border: b,
}
}
package schema_details_page
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"ktea/kontext"
"ktea/sradmin"
"ktea/styles"
"ktea/ui"
"ktea/ui/components/cmdbar"
"ktea/ui/components/notifier"
"strconv"
)
type CmdBar struct {
notifierWidget cmdbar.CmdBar
deleteCmdBar *cmdbar.DeleteCmdBar[int]
active cmdbar.CmdBar
}
func (c *CmdBar) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
if c.active != nil {
return c.active.View(ktx, renderer)
}
return ""
}
func (c *CmdBar) Update(msg tea.Msg, selection string) (tea.Msg, tea.Cmd) {
// when the notifier is active and has priority (because of a loading spinner) it should handle all msgs
if c.active == c.notifierWidget {
if c.notifierWidget.(*cmdbar.NotifierCmdBar).Notifier.HasPriority() {
active, pmsg, cmd := c.active.Update(msg)
if !active {
c.active = nil
}
return pmsg, cmd
}
}
// notifier was not actively spinning
// if it is able to handle the msg it will return nil and the processing can stop
active, pmsg, cmd := c.notifierWidget.Update(msg)
if active && pmsg == nil {
c.active = c.notifierWidget
return msg, cmd
}
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "f2":
active, pmsg, cmd := c.deleteCmdBar.Update(msg)
if active {
c.active = c.deleteCmdBar
version, err := strconv.Atoi(selection)
if err != nil {
panic(fmt.Sprintf("Version (%s) cannot be converted to int", selection))
}
c.deleteCmdBar.Delete(version)
} else {
c.active = nil
}
return pmsg, cmd
}
}
if c.active != nil {
active, pmsg, cmd := c.active.Update(msg)
if !active {
c.active = nil
}
return pmsg, cmd
}
return msg, nil
}
func NewCmdBar(deleteFunc cmdbar.DeleteFn[int], schemaDeletedNotifier cmdbar.NotificationHandler[sradmin.SchemaDeletedMsg]) *CmdBar {
schemaListingStartedNotifier := func(msg sradmin.SchemaListingStarted, m *notifier.Model) (bool, tea.Cmd) {
cmd := m.SpinWithLoadingMsg("Loading schema")
return true, cmd
}
schemaListedNotifier := func(msg sradmin.SchemasListed, m *notifier.Model) (bool, tea.Cmd) {
m.Idle()
return false, nil
}
schemaDeletionStartedNotifier := func(msg sradmin.SchemaDeletionStartedMsg, m *notifier.Model) (bool, tea.Cmd) {
cmd := m.SpinWithLoadingMsg("Deleting schema version " + strconv.Itoa(msg.Version))
return true, cmd
}
notifierCmdBar := cmdbar.NewNotifierCmdBar("schema-details-cmd-bar")
cmdbar.WithMsgHandler(notifierCmdBar, schemaListingStartedNotifier)
cmdbar.WithMsgHandler(notifierCmdBar, schemaDeletionStartedNotifier)
cmdbar.WithMsgHandler(notifierCmdBar, schemaDeletedNotifier)
cmdbar.WithMsgHandler(notifierCmdBar, schemaListedNotifier)
cmdbar.WithMsgHandler(notifierCmdBar, func(msg SchemaCopiedMsg, m *notifier.Model) (bool, tea.Cmd) {
m.ShowSuccessMsg("Schema copied")
return true, m.AutoHideCmd("schema-details-cmd-bar")
})
cmdbar.WithMsgHandler(notifierCmdBar, func(msg CopyErrorMsg, m *notifier.Model) (bool, tea.Cmd) {
m.ShowErrorMsg("Copy failed", msg.Err)
return true, m.AutoHideCmd("schema-details-cmd-bar")
})
deleteMsgFunc := func(schemaId int) string {
return renderFG("Delete version ", styles.ColorIndigo) +
renderFG(fmt.Sprintf("%d", schemaId), styles.ColorWhite) +
renderFG(" of schema?", styles.ColorIndigo)
}
return &CmdBar{
notifierWidget: notifierCmdBar,
active: notifierCmdBar,
deleteCmdBar: cmdbar.NewDeleteCmdBar[int](deleteMsgFunc, deleteFunc),
}
}
func renderFG(value string, color string) string {
return lipgloss.NewStyle().
Foreground(lipgloss.Color(color)).
Bold(true).
Render(value)
}
package schema_details_page
import (
"fmt"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"ktea/kontext"
"ktea/sradmin"
"ktea/styles"
"ktea/ui"
"ktea/ui/clipper"
"ktea/ui/components/chips"
"ktea/ui/components/notifier"
"ktea/ui/components/statusbar"
"ktea/ui/pages/nav"
"slices"
"sort"
"strconv"
"time"
)
type Model struct {
cmdbar *CmdBar
schemas []sradmin.Schema
vp *viewport.Model
subject sradmin.Subject
versionChips *chips.Model
schemaLister sradmin.VersionLister
activeSchema *sradmin.Schema
atLeastOneSchemaDeleted bool
updatedSchemas []sradmin.Schema
clipWriter clipper.Writer
}
type SchemaCopiedMsg struct {
}
type CopyErrorMsg struct {
Err error
}
func (m *Model) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
var views []string
views = append(views, m.cmdbar.View(ktx, renderer))
if m.schemas != nil {
if m.vp == nil {
vp := viewport.New(ktx.WindowWidth-3, ktx.AvailableHeight-4)
m.vp = &vp
m.createVersionChipsView()
}
if m.vp != nil {
if len(m.updatedSchemas) != 0 && len(m.schemas) != len(m.updatedSchemas) {
m.schemas = m.updatedSchemas
m.createVersionChipsView()
}
m.vp.Height = ktx.AvailableHeight - 5
m.vp.Width = ktx.WindowWidth - 3
views = append(views, lipgloss.NewStyle().
PaddingTop(1).
PaddingLeft(1).
Render(m.versionChips.View(ktx, renderer)))
views = append(views, lipgloss.JoinHorizontal(lipgloss.Top,
lipgloss.NewStyle().
PaddingTop(0).
PaddingLeft(1).
Render("ID : "),
lipgloss.NewStyle().
Bold(true).
Render(m.activeSchema.Id)))
m.vp.SetContent(ui.PrettyPrintJson(m.activeSchema.Value))
views = append(views, renderer.RenderWithStyle(m.vp.View(), styles.TextViewPort))
}
} else {
m.versionChips = chips.New("Versions")
}
return ui.JoinVertical(lipgloss.Top, views...)
}
func (m *Model) createVersionChipsView() {
var versions []string
for _, schema := range m.schemas {
versions = append(versions, strconv.Itoa(schema.Version))
}
m.versionChips = chips.New("Versions", versions...)
m.versionChips.ActivateByLabel(strconv.Itoa(m.activeSchema.Version))
}
func (m *Model) Update(msg tea.Msg) tea.Cmd {
var cmds []tea.Cmd
if m.cmdbar.active == nil {
cmd := m.versionChips.Update(msg)
cmds = append(cmds, cmd)
}
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "esc":
if m.cmdbar.active != nil {
pmsg, _ := m.cmdbar.Update(msg, m.versionChips.SelectedLabel())
if pmsg == nil {
return nil
}
}
return ui.PublishMsg(nav.LoadSubjectsPageMsg{Refresh: m.atLeastOneSchemaDeleted})
case "enter":
if m.cmdbar.active == nil {
version, _ := strconv.Atoi(m.versionChips.SelectedLabel())
m.activeSchema = nil
for _, schema := range m.schemas {
if schema.Version == version {
m.activeSchema = &schema
}
}
if m.activeSchema == nil {
panic("No schema found that matches " + m.versionChips.SelectedLabel())
}
}
case "c":
err := m.clipWriter.Write(m.activeSchema.Value)
if err != nil {
cmds = append(cmds, ui.PublishMsg(CopyErrorMsg{Err: err}))
} else {
cmds = append(cmds, ui.PublishMsg(SchemaCopiedMsg{}))
}
}
case sradmin.SchemasListed:
m.schemas = msg.Schemas
sort.Slice(m.schemas, func(i int, j int) bool {
return m.schemas[i].Version < m.schemas[j].Version
})
m.activeSchema = m.latestSchema()
case sradmin.SchemaListingStarted:
cmds = append(cmds, msg.AwaitCompletion)
case sradmin.SchemaDeletionStartedMsg:
cmds = append(cmds, msg.AwaitCompletion)
case sradmin.SchemaDeletedMsg:
m.atLeastOneSchemaDeleted = true
for i, schema := range m.schemas {
if schema.Version == msg.Version {
m.updatedSchemas = slices.Delete(m.schemas, i, i+1)
break
}
}
if len(m.updatedSchemas) == 0 {
cmds = append(cmds, func() tea.Msg {
time.Sleep(5 * time.Second)
return nav.LoadSubjectsPageMsg{Refresh: true}
})
}
}
msg, cmd := m.cmdbar.Update(msg, m.versionChips.SelectedLabel())
cmds = append(cmds, cmd)
if m.vp != nil {
vp, cmd := m.vp.Update(msg)
m.vp = &vp
cmds = append(cmds, cmd)
}
return tea.Batch(cmds...)
}
func (m *Model) Shortcuts() []statusbar.Shortcut {
return []statusbar.Shortcut{
{
Name: "Prev Version",
Keybinding: "h/←",
},
{
Name: "Next Version",
Keybinding: "l/→",
},
{
Name: "Select Version",
Keybinding: "enter",
},
{
Name: "Delete Version",
Keybinding: "F2",
},
{
Name: "Copy Schema",
Keybinding: "c",
},
{
Name: "Go Back",
Keybinding: "esc",
},
}
}
func (m *Model) Title() string {
schema := m.latestSchema()
if schema != nil {
// wait until schemas have been loaded
return "Subjects / " + m.subject.Name + " / Versions / " + strconv.Itoa(schema.Version)
}
return ""
}
func (m *Model) latestSchema() *sradmin.Schema {
if m.schemas == nil {
return nil
}
var latest = slices.MaxFunc(m.schemas, func(a sradmin.Schema, b sradmin.Schema) int {
if a.Version >= b.Version {
return a.Version
}
return b.Version
})
return &latest
}
func New(
schemaLister sradmin.VersionLister,
schemaDeleter sradmin.SchemaDeleter,
subject sradmin.Subject,
clipWriter clipper.Writer,
) (*Model, tea.Cmd) {
model := &Model{
subject: subject,
schemaLister: schemaLister,
clipWriter: clipWriter,
}
deleteFunc := func(version int) tea.Cmd {
return func() tea.Msg {
return schemaDeleter.DeleteSchema(subject.Name, version)
}
}
schemaDeletedMsg := func(msg sradmin.SchemaDeletedMsg, m *notifier.Model) (bool, tea.Cmd) {
if len(model.updatedSchemas) == 0 {
m.ShowSuccessMsg(fmt.Sprintf("Deleted last schema with version %d of subject, returning to subjects list.", msg.Version))
} else {
m.ShowSuccessMsg(fmt.Sprintf("Schema with version %d has been deleted", msg.Version))
}
return true, m.AutoHideCmd("schema-details-cmd-bar")
}
model.cmdbar = NewCmdBar(deleteFunc, schemaDeletedMsg)
return model, func() tea.Msg {
return schemaLister.ListVersions(subject.Name, subject.Versions)
}
}
package subjects_page
import (
"fmt"
"github.com/charmbracelet/log"
"ktea/kontext"
"ktea/sradmin"
"ktea/styles"
"ktea/ui"
"ktea/ui/components/border"
"ktea/ui/components/cmdbar"
"ktea/ui/components/notifier"
"ktea/ui/components/statusbar"
ktable "ktea/ui/components/table"
"ktea/ui/pages/nav"
"reflect"
"sort"
"strconv"
"strings"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type state int
const (
initialized state = iota
subjectsLoaded
loading
noSubjectsFound
deleting
activeTabLbl = border.TabLabel("active")
deletedTabLbl = border.TabLabel("deleted")
)
type Model struct {
table table.Model
rows []table.Row
tcb *TableCmdsBar
border *border.Model
subjects []sradmin.Subject
visibleSubjects []sradmin.Subject
tableFocussed bool
lister sradmin.SubjectLister
gCompLister sradmin.GlobalCompatibilityLister
state state
// when last subject in table is deleted no subject is focussed anymore
deletedLast bool
sort cmdbar.SortLabel
globalCompLevel string
goToTop bool
}
func (m *Model) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
if m.state == noSubjectsFound {
return m.border.View(lipgloss.NewStyle().
Width(ktx.WindowWidth - 2).
Height(ktx.AvailableHeight - 3).
AlignVertical(lipgloss.Center).
AlignHorizontal(lipgloss.Center).
Render("No Subjects Found"))
}
cmdBarView := m.tcb.View(ktx, renderer)
available := ktx.WindowWidth - 8
subjCol := int(float64(available) * 0.8)
versionCol := int(float64(available) * 0.08)
compCol := available - subjCol - versionCol
m.table.SetColumns([]table.Column{
{m.columnTitle("Subject Name"), subjCol},
{m.columnTitle("Versions"), versionCol},
{m.columnTitle("Compatibility"), compCol},
})
m.table.SetHeight(ktx.AvailableHeight - 3)
m.table.SetWidth(ktx.WindowWidth - 2)
m.table.SetRows(m.rows)
if m.deletedLast && m.table.SelectedRow() == nil {
m.table.GotoBottom()
m.deletedLast = false
}
if m.table.SelectedRow() == nil && len(m.table.Rows()) > 0 {
m.table.GotoTop()
}
if m.goToTop {
m.table.GotoTop()
m.goToTop = false
}
return ui.JoinVertical(lipgloss.Top, cmdBarView, m.border.View(m.table.View()))
}
func (m *Model) columnTitle(title string) string {
if m.sort.Label == title {
return lipgloss.NewStyle().
Foreground(lipgloss.Color(styles.ColorPink)).
Bold(true).
Render(m.sort.Direction.String()) + " " + title
}
return title
}
func (m *Model) Update(msg tea.Msg) tea.Cmd {
log.Debug("Received Update", "msg", reflect.TypeOf(msg))
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "tab":
m.border.NextTab()
m.table.GotoTop()
m.tcb.Reset()
case "f5":
m.state = loading
m.subjects = nil
return m.lister.ListSubjects
case "ctrl+n":
if m.state != loading && m.state != deleting {
return ui.PublishMsg(nav.LoadCreateSubjectPageMsg{})
}
case "enter":
// only accept enter when the table is focussed
if !m.tcb.IsFocussed() {
// ignore enter when there are no schemas loaded
if m.state == subjectsLoaded && len(m.subjects) > 0 {
return ui.PublishMsg(
nav.LoadSchemaDetailsPageMsg{
Subject: *m.SelectedSubject(),
},
)
}
}
}
case sradmin.SubjectListingStartedMsg:
m.state = loading
cmds = append(cmds, msg.AwaitCompletion)
case sradmin.SubjectsListedMsg:
if len(msg.Subjects) > 0 {
m.state = subjectsLoaded
m.subjects = msg.Subjects
m.goToTop = true
m.tcb.ResetSearch()
} else {
m.state = noSubjectsFound
}
case sradmin.GlobalCompatibilityListingStartedMsg:
cmds = append(cmds, msg.AwaitCompletion)
case sradmin.GlobalCompatibilityListedMsg:
m.globalCompLevel = msg.Compatibility
case sradmin.SubjectDeletionStartedMsg:
m.state = deleting
cmds = append(cmds, msg.AwaitCompletion)
case sradmin.SubjectDeletedMsg:
// set state back to loaded after removing the deleted subject
m.state = subjectsLoaded
m.removeDeletedSubjectFromModel(msg.SubjectName)
if len(m.subjects) == 0 {
m.state = noSubjectsFound
}
}
_, cmd := m.tcb.Update(msg, m.SelectedSubject())
m.tableFocussed = !m.tcb.IsFocussed()
cmds = append(cmds, cmd)
var visSubjects []sradmin.Subject
for _, subject := range m.subjects {
if m.border.ActiveTab() == deletedTabLbl && subject.Deleted {
visSubjects = append(visSubjects, subject)
} else if m.border.ActiveTab() == activeTabLbl && !subject.Deleted {
visSubjects = append(visSubjects, subject)
}
}
visSubjects = m.filterSubjectsBySearchTerm(visSubjects)
m.visibleSubjects = visSubjects
m.rows = m.createRows(visSubjects)
// make sure table navigation is off when the cmdbar is focussed
if !m.tcb.IsFocussed() {
t, cmd := m.table.Update(msg)
m.table = t
cmds = append(cmds, cmd)
}
if m.tcb.HasSearchedAtLeastOneChar() {
m.table.GotoTop()
}
return tea.Batch(cmds...)
}
func (m *Model) removeDeletedSubjectFromModel(subjectName string) {
for i, subject := range m.subjects {
if subject.Name == subjectName {
if i == len(m.subjects)-1 {
m.deletedLast = true
}
m.subjects = append(m.subjects[:i], m.subjects[i+1:]...)
}
}
}
func (m *Model) createRows(subjects []sradmin.Subject) []table.Row {
var rows []table.Row
for _, subject := range subjects {
rows = append(rows, table.Row{
subject.Name,
strconv.Itoa(len(subject.Versions)),
subject.Compatibility,
})
rows = rows
}
sort.SliceStable(rows, func(i, j int) bool {
switch m.sort.Label {
case "Subject Name":
if m.sort.Direction == cmdbar.Asc {
return rows[i][0] < rows[j][0]
}
return rows[i][0] > rows[j][0]
case "Versions":
countI, _ := strconv.Atoi(rows[i][1])
countJ, _ := strconv.Atoi(rows[j][1])
if m.sort.Direction == cmdbar.Asc {
return countI < countJ
}
return countI > countJ
case "Compatibility":
if m.sort.Direction == cmdbar.Asc {
return rows[i][2] < rows[j][2]
}
return rows[i][2] > rows[j][2]
default:
return rows[i][0] < rows[j][0]
}
})
return rows
}
func (m *Model) filterSubjectsBySearchTerm(subjects []sradmin.Subject) []sradmin.Subject {
var resSubjects []sradmin.Subject
searchTerm := m.tcb.GetSearchTerm()
for _, subject := range subjects {
if searchTerm != "" {
if strings.Contains(strings.ToUpper(subject.Name), strings.ToUpper(searchTerm)) {
resSubjects = append(resSubjects, subject)
}
} else {
resSubjects = append(resSubjects, subject)
}
}
return resSubjects
}
func (m *Model) Shortcuts() []statusbar.Shortcut {
shortcuts := m.tcb.Shortcuts()
if shortcuts == nil {
var extraShortcuts []statusbar.Shortcut
if len(m.visibleSubjects) > 0 {
var deleteType string
var tabShortcut statusbar.Shortcut
if m.border.ActiveTab() == activeTabLbl {
deleteType = "soft"
tabShortcut = statusbar.Shortcut{
Name: "Deleted Subjects",
Keybinding: "tab",
}
} else {
deleteType = "hard"
tabShortcut = statusbar.Shortcut{
Name: "Active Subjects",
Keybinding: "tab",
}
}
extraShortcuts = []statusbar.Shortcut{
tabShortcut,
{
Name: "Search",
Keybinding: "/",
},
{
Name: fmt.Sprintf("Delete (%s)", deleteType),
Keybinding: "F2",
},
}
}
return append([]statusbar.Shortcut{
{
Name: "Register New Schema",
Keybinding: "C-n",
},
//{
// Name: "Evolve Selected Schema",
// Keybinding: "C-e",
//},
{
Name: "Refresh",
Keybinding: "F5",
},
}, extraShortcuts...)
} else {
return shortcuts
}
}
func (m *Model) SelectedSubject() *sradmin.Subject {
if len(m.visibleSubjects) > 0 {
selectedRow := m.table.SelectedRow()
if selectedRow != nil {
for _, subject := range m.visibleSubjects {
if subject.Name == selectedRow[0] {
return &subject
}
}
}
return nil
}
return nil
}
func (m *Model) Title() string {
return "Subjects"
}
func New(srClient sradmin.Client) (*Model, tea.Cmd) {
model := Model{
table: ktable.NewDefaultTable(),
tableFocussed: true,
lister: srClient,
state: initialized,
}
deleteMsgFn := func(subject sradmin.Subject) string {
var deleteType string
if model.border.ActiveTab() == activeTabLbl {
deleteType = "soft"
} else {
deleteType = "hard"
}
message := subject.Name + lipgloss.NewStyle().
Foreground(lipgloss.Color(styles.ColorIndigo)).
Bold(true).
Render(fmt.Sprintf(" will be deleted (%s)", deleteType))
return message
}
deleteFn := func(subject sradmin.Subject) tea.Cmd {
return func() tea.Msg {
if model.border.ActiveTab() == activeTabLbl {
return srClient.SoftDeleteSubject(subject.Name)
} else {
return srClient.HardDeleteSubject(subject.Name)
}
}
}
notifierCmdBar := cmdbar.NewNotifierCmdBar("subjects-page")
subjectListingStartedNotifier := func(msg sradmin.SubjectListingStartedMsg, m *notifier.Model) (bool, tea.Cmd) {
cmd := m.SpinWithLoadingMsg("Loading subjects")
return true, cmd
}
subjectsListedNotifier := func(msg sradmin.SubjectsListedMsg, m *notifier.Model) (bool, tea.Cmd) {
m.Idle()
return false, nil
}
subjectDeletionStartedNotifier := func(msg sradmin.SubjectDeletionStartedMsg, m *notifier.Model) (bool, tea.Cmd) {
cmd := m.SpinWithLoadingMsg("Deleting Subject " + msg.Subject)
return true, cmd
}
subjectListingErrorMsg := func(msg sradmin.SubjectListingErrorMsg, m *notifier.Model) (bool, tea.Cmd) {
m.ShowErrorMsg("Error listing subjects", msg.Err)
return true, nil
}
subjectDeletedNotifier := func(msg sradmin.SubjectDeletedMsg, m *notifier.Model) (bool, tea.Cmd) {
m.ShowSuccessMsg("Subject deleted")
return true, m.AutoHideCmd("subjects-page")
}
subjectDeletionErrorNotifier := func(msg sradmin.SubjectDeletionErrorMsg, m *notifier.Model) (bool, tea.Cmd) {
m.ShowErrorMsg("Failed to delete subject", msg.Err)
return true, m.AutoHideCmd("subjects-page")
}
cmdbar.WithMsgHandler(notifierCmdBar, subjectListingStartedNotifier)
cmdbar.WithMsgHandler(notifierCmdBar, subjectsListedNotifier)
cmdbar.WithMsgHandler(notifierCmdBar, subjectDeletionStartedNotifier)
cmdbar.WithMsgHandler(notifierCmdBar, subjectListingErrorMsg)
cmdbar.WithMsgHandler(notifierCmdBar, subjectDeletedNotifier)
cmdbar.WithMsgHandler(notifierCmdBar, subjectDeletionErrorNotifier)
cmdbar.WithMsgHandler(
notifierCmdBar,
func(
msg ui.RegainedFocusMsg,
m *notifier.Model,
) (bool, tea.Cmd) {
if model.state == loading {
cmd := m.SpinWithLoadingMsg("Loading subjects")
return true, cmd
}
if model.state == deleting {
cmd := m.SpinWithLoadingMsg("Deleting Subject")
return true, cmd
}
return false, nil
},
)
sortByBar := cmdbar.NewSortByCmdBar(
[]cmdbar.SortLabel{
{
Label: "Subject Name",
Direction: cmdbar.Asc,
},
{
Label: "Versions",
Direction: cmdbar.Desc,
},
{
Label: "Compatibility",
Direction: cmdbar.Asc,
},
},
cmdbar.WithSortSelectedCallback(func(label cmdbar.SortLabel) {
model.sort = label
}),
)
model.sort = sortByBar.SortedBy()
model.border = border.New(
border.WithInnerPaddingTop(),
border.WithTabs(
border.Tab{Title: "Active Subjects", TabLabel: activeTabLbl},
border.Tab{Title: "Deleted Subjects (soft)", TabLabel: deletedTabLbl},
),
border.WithTitleFn(func() string {
var compLevel string
if model.globalCompLevel == "" {
compLevel = ""
} else {
compLevel = border.KeyValueTitle("Global Compatibility", model.globalCompLevel, model.tableFocussed)
}
return border.KeyValueTitle("Total Subjects", fmt.Sprintf(" %d/%d", len(model.rows), len(model.subjects)), model.tableFocussed) + compLevel
}))
model.tcb = NewTableCmdsBar(
srClient,
cmdbar.NewDeleteCmdBar(deleteMsgFn, deleteFn),
cmdbar.NewSearchCmdBar("Search subjects by name"),
notifierCmdBar,
sortByBar,
)
return &model, tea.Batch(
srClient.ListSubjects,
srClient.ListGlobalCompatibility,
)
}
package subjects_page
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"ktea/kontext"
"ktea/sradmin"
"ktea/styles"
"ktea/ui"
"ktea/ui/components/cmdbar"
"ktea/ui/components/statusbar"
)
type TableCmdsBar struct {
mainCBar *cmdbar.TableCmdsBar[sradmin.Subject]
hardDeleteCBar *cmdbar.DeleteCmdBar[sradmin.Subject]
}
func (m *TableCmdsBar) Update(msg tea.Msg, selection *sradmin.Subject) (tea.Msg, tea.Cmd) {
// If the hardDeleteCBar is already focused, pass all messages to it.
if m.hardDeleteCBar.IsFocussed() {
active, pmsg, cmd := m.hardDeleteCBar.Update(msg)
if !active || pmsg != nil {
// The hardDeleteCBar is no longer focused or the msg has not been handled by the hardDeleteCBar,
// so reset its state and return to the main tcb.
update, t := m.mainCBar.Update(pmsg, selection)
if m.mainCBar.IsFocussed() {
// tcb gained focus again we need to hide the hardDeleteCBar
m.hardDeleteCBar.Hide()
}
return update, t
}
return pmsg, cmd
}
// Check for the F4 key press to activate the hardDeleteCBar.
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "f4" && selection != nil {
// Toggle the hardDeleteCBar.
active, pmsg, cmd := m.hardDeleteCBar.Update(msg)
if active {
m.hardDeleteCBar.Delete(*selection)
// hardDeleteCBar is active so we can deactivate tcb
m.mainCBar.Hide()
}
return pmsg, cmd
}
}
// Pass all other messages to the nested tcb.
return m.mainCBar.Update(msg, selection)
}
func (m *TableCmdsBar) IsFocussed() bool {
return m.mainCBar.IsFocussed() || m.hardDeleteCBar.IsFocussed()
}
func (m *TableCmdsBar) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
var view string
view = m.mainCBar.View(ktx, renderer)
if view != "" {
return view
}
if m.hardDeleteCBar.IsFocussed() {
return m.hardDeleteCBar.View(ktx, renderer)
}
return ""
}
func (m *TableCmdsBar) Shortcuts() []statusbar.Shortcut {
shortcuts := m.hardDeleteCBar.Shortcuts()
if shortcuts != nil {
return shortcuts
}
return m.mainCBar.Shortcuts()
}
func (m *TableCmdsBar) HasSearchedAtLeastOneChar() bool {
return m.mainCBar.HasSearchedAtLeastOneChar()
}
func (m *TableCmdsBar) GetSearchTerm() string {
return m.mainCBar.GetSearchTerm()
}
func (m *TableCmdsBar) ResetSearch() {
m.mainCBar.ResetSearch()
}
func (m *TableCmdsBar) Reset() {
m.mainCBar.ResetSearch()
m.mainCBar.Hide()
m.hardDeleteCBar.Hide()
}
func NewTableCmdsBar(
deleter sradmin.SubjectDeleter,
deleteCmdbar *cmdbar.DeleteCmdBar[sradmin.Subject],
searchCmdBar *cmdbar.SearchCmdBar,
notifierCmdBar *cmdbar.NotifierCmdBar,
sortByCmdBar *cmdbar.SortByCmdBar,
) *TableCmdsBar {
deleteFn := func(subject sradmin.Subject) tea.Cmd {
return func() tea.Msg {
return deleter.HardDeleteSubject(subject.Name)
}
}
deleteMsgFn := func(subject sradmin.Subject) string {
message := subject.Name + lipgloss.NewStyle().
Foreground(lipgloss.Color(styles.ColorIndigo)).
Bold(true).
Render(" will be deleted permanently (hard)")
return message
}
hardDeleteCBar := cmdbar.NewDeleteCmdBar[sradmin.Subject](
deleteMsgFn,
deleteFn,
cmdbar.WithDeleteKey[sradmin.Subject]("f4"),
)
return &TableCmdsBar{
cmdbar.NewTableCmdsBar[sradmin.Subject](
deleteCmdbar,
searchCmdBar,
notifierCmdBar,
sortByCmdBar,
),
hardDeleteCBar,
}
}
package topics_page
import (
"context"
"fmt"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/log"
"ktea/kadmin"
"ktea/kontext"
"ktea/styles"
"ktea/ui"
"ktea/ui/components/cmdbar"
"ktea/ui/components/notifier"
"ktea/ui/components/statusbar"
ktable "ktea/ui/components/table"
"ktea/ui/pages/nav"
"reflect"
"slices"
"sort"
"strconv"
"strings"
)
const name = "topics-page"
type state int
const (
stateRefreshing state = iota
stateLoading
stateLoaded
)
type Model struct {
topics []kadmin.ListedTopic
table table.Model
shortcuts []statusbar.Shortcut
tcb *cmdbar.TableCmdsBar[string]
rows []table.Row
lister kadmin.TopicLister
ctx context.Context
tableFocussed bool
state state
sortByCmdBar *cmdbar.SortByCmdBar
goToTop bool
}
func (m *Model) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
var views []string
cmdBarView := m.tcb.View(ktx, renderer)
views = append(views, cmdBarView)
m.table.SetWidth(ktx.WindowWidth - 2)
m.table.SetColumns([]table.Column{
{m.sortByCmdBar.PrefixSortIcon("Name"), int(float64(ktx.WindowWidth-7) * 0.6)},
{m.sortByCmdBar.PrefixSortIcon("Partitions"), int(float64(ktx.WindowWidth-7) * 0.3)},
{m.sortByCmdBar.PrefixSortIcon("Replicas"), int(float64(ktx.WindowWidth-7) * 0.1)},
})
m.table.SetRows(m.rows)
m.table.SetHeight(ktx.AvailableHeight - 2)
if m.table.SelectedRow() == nil && len(m.table.Rows()) > 0 {
m.goToTop = true
}
if m.goToTop {
m.table.GotoTop()
m.goToTop = false
}
styledTable := renderer.RenderWithStyle(m.table.View(), styles.Table.Blur)
embeddedText := map[styles.BorderPosition]styles.EmbeddedTextFunc{
styles.TopMiddleBorder: styles.EmbeddedBorderText("Total Topics", fmt.Sprintf("%d/%d", len(m.rows), len(m.topics))),
styles.BottomMiddleBorder: styles.EmbeddedBorderText("Total Topics", fmt.Sprintf("%d/%d", len(m.rows), len(m.topics))),
}
tableView := styles.Borderize(styledTable, m.tableFocussed, embeddedText)
return ui.JoinVertical(lipgloss.Top, cmdBarView, tableView)
}
func (m *Model) Update(msg tea.Msg) tea.Cmd {
log.Debug("Received Update", "msg", reflect.TypeOf(msg))
cmds := make([]tea.Cmd, 2)
var cmd tea.Cmd
cmds = append(cmds, cmd)
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+n":
return ui.PublishMsg(nav.LoadCreateTopicPageMsg{})
case "ctrl+o":
if m.SelectedTopic() == nil {
return nil
}
return ui.PublishMsg(nav.LoadTopicConfigPageMsg{})
case "ctrl+p":
if m.SelectedTopic() == nil {
return nil
}
return ui.PublishMsg(nav.LoadPublishPageMsg{Topic: m.SelectedTopic()})
case "f5":
m.topics = nil
m.state = stateRefreshing
return m.lister.ListTopics
case "L":
if m.SelectedTopic() == nil {
return nil
}
return ui.PublishMsg(nav.LoadLiveConsumePageMsg{Topic: m.SelectedTopic()})
case "enter":
// only accept enter when the table is focussed
if !m.tcb.IsFocussed() {
if m.SelectedTopic() != nil {
return ui.PublishMsg(nav.LoadConsumptionFormPageMsg{
Topic: m.SelectedTopic(),
})
}
}
}
case spinner.TickMsg:
selectedTopic := m.SelectedTopicName()
_, c := m.tcb.Update(msg, &selectedTopic)
if c != nil {
cmds = append(cmds, c)
}
case kadmin.TopicDeletionStartedMsg:
cmds = append(cmds, msg.AwaitCompletion)
case kadmin.TopicListingStartedMsg:
cmds = append(cmds, msg.AwaitTopicListCompletion)
case kadmin.TopicsListedMsg:
m.tcb.ResetSearch()
m.topics = msg.Topics
m.goToTop = true
m.state = stateLoaded
case kadmin.TopicDeletedMsg:
m.topics = slices.DeleteFunc(
m.topics,
func(t kadmin.ListedTopic) bool { return msg.TopicName == t.Name },
)
}
name := m.SelectedTopicName()
msg, cmd = m.tcb.Update(msg, &name)
m.tableFocussed = !m.tcb.IsFocussed()
cmds = append(cmds, cmd)
m.rows = m.createRows()
// make sure table navigation is off when the cmdbar is focussed
if !m.tcb.IsFocussed() {
t, cmd := m.table.Update(msg)
m.table = t
cmds = append(cmds, cmd)
}
if m.tcb.HasSearchedAtLeastOneChar() {
m.goToTop = true
}
return tea.Batch(cmds...)
}
func (m *Model) createRows() []table.Row {
var rows []table.Row
for _, topic := range m.topics {
if m.tcb.GetSearchTerm() != "" {
if strings.Contains(strings.ToLower(topic.Name), strings.ToLower(m.tcb.GetSearchTerm())) {
rows = append(
rows,
table.Row{
topic.Name,
strconv.Itoa(topic.PartitionCount),
strconv.Itoa(topic.Replicas),
},
)
}
} else {
rows = append(
rows,
table.Row{
topic.Name,
strconv.Itoa(topic.PartitionCount),
strconv.Itoa(topic.Replicas),
},
)
}
}
sort.SliceStable(rows, func(i, j int) bool {
switch m.sortByCmdBar.SortedBy().Label {
case "Name":
if m.sortByCmdBar.SortedBy().Direction == cmdbar.Asc {
return rows[i][0] < rows[j][0]
}
return rows[i][0] > rows[j][0]
case "Partitions":
partitionI, _ := strconv.Atoi(rows[i][1])
partitionJ, _ := strconv.Atoi(rows[j][1])
if m.sortByCmdBar.SortedBy().Direction == cmdbar.Asc {
return partitionI < partitionJ
}
return partitionI > partitionJ
case "Replicas":
replicasI, _ := strconv.Atoi(rows[i][2])
replicasJ, _ := strconv.Atoi(rows[j][2])
if m.sortByCmdBar.SortedBy().Direction == cmdbar.Asc {
return replicasI < replicasJ
}
return replicasI > replicasJ
case "~ Record Count":
countI, _ := strconv.Atoi(strings.ReplaceAll(rows[i][3], ",", ""))
countJ, _ := strconv.Atoi(strings.ReplaceAll(rows[j][3], ",", ""))
if m.sortByCmdBar.SortedBy().Direction == cmdbar.Asc {
return countI < countJ
}
return countI > countJ
default:
panic(fmt.Sprintf("unexpected sort label: %s", m.sortByCmdBar.SortedBy().Label))
}
})
return rows
}
func (m *Model) SelectedTopic() *kadmin.ListedTopic {
selectedTopic := m.SelectedTopicName()
for _, t := range m.topics {
if t.Name == selectedTopic {
return &t
}
}
return nil
}
func (m *Model) SelectedTopicName() string {
selectedRow := m.table.SelectedRow()
var selectedTopic string
if selectedRow != nil {
selectedTopic = selectedRow[0]
}
return selectedTopic
}
func (m *Model) Title() string {
return "Topics"
}
func (m *Model) Shortcuts() []statusbar.Shortcut {
if m.tcb.IsFocussed() {
shortCuts := m.tcb.Shortcuts()
if shortCuts != nil {
return shortCuts
}
}
return m.shortcuts
}
func (m *Model) Refresh() tea.Cmd {
m.topics = nil
return m.lister.ListTopics
}
func New(topicDeleter kadmin.TopicDeleter, lister kadmin.TopicLister) (*Model, tea.Cmd) {
var m = Model{}
m.shortcuts = []statusbar.Shortcut{
{"Consume", "enter"},
{"Live Consume", "S-l"},
{"Search", "/"},
{"Produce", "C-p"},
{"Create", "C-n"},
{"Configs", "C-o"},
{"Delete", "F2"},
{"Sort", "F3"},
{"Refresh", "F5"},
}
m.table = ktable.NewDefaultTable()
deleteMsgFunc := func(topic string) string {
message := topic + lipgloss.NewStyle().
Foreground(lipgloss.Color(styles.ColorIndigo)).
Bold(true).
Render(" will be deleted permanently")
return message
}
deleteFunc := func(topic string) tea.Cmd {
return func() tea.Msg {
return topicDeleter.DeleteTopic(topic)
}
}
notifierCmdBar := cmdbar.NewNotifierCmdBar(name)
cmdbar.WithMsgHandler(
notifierCmdBar,
func(
msg kadmin.TopicListingStartedMsg,
m *notifier.Model,
) (bool, tea.Cmd) {
cmd := m.SpinWithLoadingMsg("Loading Topics")
return true, cmd
},
)
cmdbar.WithMsgHandler(
notifierCmdBar,
func(
msg ui.RegainedFocusMsg,
model *notifier.Model,
) (bool, tea.Cmd) {
if m.state == stateRefreshing || m.state == stateLoading {
log.Debug("skldfjkslfjsdlf//////////", m.state)
cmd := model.SpinWithLoadingMsg("Loading Topics")
return true, cmd
}
return false, nil
},
)
cmdbar.WithMsgHandler(
notifierCmdBar,
func(
msg kadmin.TopicsListedMsg,
m *notifier.Model,
) (bool, tea.Cmd) {
m.Idle()
return false, nil
},
)
cmdbar.WithMsgHandler(
notifierCmdBar,
func(
msg kadmin.TopicListedErrorMsg,
m *notifier.Model,
) (bool, tea.Cmd) {
m.ShowErrorMsg("Error listing Topics", msg.Err)
return true, nil
},
)
cmdbar.WithMsgHandler(
notifierCmdBar,
func(
msg kadmin.TopicDeletedMsg,
m *notifier.Model,
) (bool, tea.Cmd) {
m.ShowSuccessMsg("Topic Deleted")
return true, m.AutoHideCmd(name)
},
)
cmdbar.WithMsgHandler(
notifierCmdBar,
func(
msg kadmin.TopicDeletionStartedMsg,
m *notifier.Model,
) (bool, tea.Cmd) {
cmd := m.SpinWithLoadingMsg("Deleting Topic")
return true, cmd
},
)
cmdbar.WithMsgHandler(
notifierCmdBar,
func(
msg kadmin.TopicDeletionErrorMsg,
m *notifier.Model,
) (bool, tea.Cmd) {
m.ShowErrorMsg("Error Deleting Topic", msg.Err)
return true, m.AutoHideCmd(name)
},
)
sortByCmdBar := cmdbar.NewSortByCmdBar(
[]cmdbar.SortLabel{
{
Label: "Name",
Direction: cmdbar.Asc,
},
{
Label: "Partitions",
Direction: cmdbar.Desc,
},
{
Label: "Replicas",
Direction: cmdbar.Desc,
},
},
)
m.sortByCmdBar = sortByCmdBar
bar := cmdbar.NewSearchCmdBar("Search topics by name")
m.tcb = cmdbar.NewTableCmdsBar[string](
cmdbar.NewDeleteCmdBar(deleteMsgFunc, deleteFunc),
bar,
notifierCmdBar,
sortByCmdBar,
)
m.lister = lister
m.state = stateLoading
var cmds []tea.Cmd
cmds = append(cmds, m.lister.ListTopics)
return &m, tea.Batch(cmds...)
}
package ui
import (
tea "github.com/charmbracelet/bubbletea"
)
func PublishMsg(msg tea.Msg) tea.Cmd {
return func() tea.Msg {
return msg
}
}
package ui
import (
"github.com/charmbracelet/lipgloss"
"ktea/kontext"
)
type Renderer struct {
ktx *kontext.ProgramKtx
}
func (r *Renderer) Render(view string) string {
height := lipgloss.Height(view)
r.ktx.HeightUsed(height)
return view
}
func (r *Renderer) RenderWithStyle(view string, style lipgloss.Style) string {
return r.Render(style.Render(view))
}
func NewRenderer(ktx *kontext.ProgramKtx) *Renderer {
return &Renderer{ktx}
}
package cgroups_tab
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"ktea/kadmin"
"ktea/kontext"
"ktea/ui"
"ktea/ui/components/statusbar"
"ktea/ui/pages/cgroups_page"
"ktea/ui/pages/cgroups_topics_page"
"ktea/ui/pages/nav"
)
type Model struct {
active nav.Page
statusbar *statusbar.Model
offsetLister kadmin.OffsetLister
cgroupLister kadmin.CGroupLister
cgroupDeleter kadmin.CGroupDeleter
cgroupsPage *cgroups_page.Model
}
func (m *Model) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
return ui.JoinVertical(
lipgloss.Top,
m.statusbar.View(ktx, renderer),
m.active.View(ktx, renderer),
)
}
func (m *Model) Update(msg tea.Msg) tea.Cmd {
var cmds []tea.Cmd
switch msg := msg.(type) {
case nav.LoadCGroupTopicsPageMsg:
cgroupsTopicsPage, cmd := cgroups_topics_page.New(m.offsetLister, msg.GroupName)
cmds = append(cmds, cmd)
m.active = cgroupsTopicsPage
return tea.Batch(cmds...)
case nav.LoadCGroupsPageMsg:
var cmd tea.Cmd
if m.cgroupsPage == nil {
m.cgroupsPage, cmd = cgroups_page.New(m.cgroupLister, m.cgroupDeleter)
}
m.active = m.cgroupsPage
cmds = append(cmds, cmd)
case kadmin.ConsumerGroupListingStartedMsg:
cmds = append(cmds, msg.AwaitCompletion)
}
cmd := m.active.Update(msg)
// always recreate the statusbar in case the active page might have changed
m.statusbar = statusbar.New(m.active)
cmds = append(cmds, cmd)
return tea.Batch(cmds...)
}
func New(
cgroupLister kadmin.CGroupLister,
cgroupDeleter kadmin.CGroupDeleter,
consumerGroupOffsetLister kadmin.OffsetLister,
) (*Model, tea.Cmd) {
cgroupsPage, cmd := cgroups_page.New(cgroupLister, cgroupDeleter)
m := &Model{}
m.offsetLister = consumerGroupOffsetLister
m.cgroupLister = cgroupLister
m.cgroupDeleter = cgroupDeleter
m.cgroupsPage = cgroupsPage
m.active = cgroupsPage
m.statusbar = statusbar.New(m.active)
return m, cmd
}
package clusters_tab
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"ktea/config"
"ktea/kadmin"
"ktea/kontext"
"ktea/sradmin"
"ktea/ui"
"ktea/ui/components/statusbar"
"ktea/ui/pages/clusters_page"
"ktea/ui/pages/create_cluster_page"
"ktea/ui/pages/nav"
)
type state int
type Model struct {
state state
active nav.Page
createPage nav.Page
config *config.Config
statusbar *statusbar.Model
ktx *kontext.ProgramKtx
kConnChecker kadmin.ConnChecker
srConnChecker sradmin.ConnChecker
escGoesBack bool
}
func (m *Model) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
var views []string
if m.statusbar != nil {
views = append(views, m.statusbar.View(ktx, renderer))
}
views = append(views, m.active.View(ktx, renderer))
return ui.JoinVertical(
lipgloss.Top,
views...,
)
}
func (m *Model) Update(msg tea.Msg) tea.Cmd {
if m.active == nil {
return nil
}
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "esc":
if m.escGoesBack {
return m.active.Update(msg)
}
return nil
case "ctrl+n":
if _, ok := m.active.(*clusters_page.Model); ok {
m.active = create_cluster_page.NewCreateClusterPage(
m.GoBack,
m.kConnChecker,
m.srConnChecker,
m.ktx.Config,
m.ktx,
[]statusbar.Shortcut{
{"Confirm", "enter"},
{"Next Field", "tab"},
{"Prev. Field", "s-tab"},
{"Reset Form", "C-r"},
{"Go Back", "esc"},
},
)
}
case "ctrl+e":
if clustersPage, ok := m.active.(*clusters_page.Model); ok {
clusterName := clustersPage.SelectedCluster()
selectedCluster := m.ktx.Config.FindClusterByName(*clusterName)
m.active = create_cluster_page.NewEditClusterPage(
m.GoBack,
m.kConnChecker,
m.srConnChecker,
m.ktx.Config,
m.ktx.Config,
m.ktx,
*selectedCluster,
create_cluster_page.WithTitle(fmt.Sprintf("Clusters / %s / Edit", selectedCluster.Name)),
)
}
}
}
// always recreate the statusbar in case the active page might have changed
m.statusbar = statusbar.New(m.active)
return m.active.Update(msg)
}
func (m *Model) GoBack() tea.Cmd {
var cmd tea.Cmd
m.active, cmd = clusters_page.New(m.ktx, m.kConnChecker)
m.statusbar = statusbar.New(m.active)
return cmd
}
func New(
ktx *kontext.ProgramKtx,
kConnChecker kadmin.ConnChecker,
srConnChecker sradmin.ConnChecker,
) (*Model, tea.Cmd) {
var cmd tea.Cmd
m := Model{}
m.kConnChecker = kConnChecker
m.srConnChecker = srConnChecker
m.ktx = ktx
m.config = ktx.Config
if m.config.HasClusters() {
var listPage, c = clusters_page.New(ktx, m.kConnChecker)
cmd = c
m.escGoesBack = true
m.active = listPage
m.statusbar = statusbar.New(m.active)
} else {
m.active = create_cluster_page.NewCreateClusterPage(
m.GoBack,
m.kConnChecker,
m.srConnChecker,
m.ktx.Config,
m.ktx,
[]statusbar.Shortcut{
{"Confirm", "enter"},
{"Next Field", "tab"},
{"Prev. Field", "s-tab"},
{"Reset Form", "C-r"},
},
create_cluster_page.WithTitle("Clusters / Register"),
)
m.escGoesBack = false
}
return &m, cmd
}
package kcon_tab
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"ktea/config"
"ktea/kcadmin"
"ktea/kontext"
"ktea/ui"
"ktea/ui/components/statusbar"
"ktea/ui/pages/kcon_clusters_page"
"ktea/ui/pages/kcon_page"
"ktea/ui/pages/nav"
"net/http"
)
type Model struct {
active nav.Page
statusbar *statusbar.Model
kconsPage *kcon_clusters_page.Model
}
func (m *Model) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
var views []string
if m.statusbar != nil {
views = append(views, m.statusbar.View(ktx, renderer))
}
views = append(views, m.active.View(ktx, renderer))
return ui.JoinVertical(lipgloss.Top, views...)
}
func (m *Model) Update(msg tea.Msg) tea.Cmd {
// always recreate the statusbar in case the active page might have changed
m.statusbar = statusbar.New(m.active)
return m.active.Update(msg)
}
func (m *Model) navBack() tea.Cmd {
m.active = m.kconsPage
m.statusbar = statusbar.New(m.active)
return nil
}
func (m *Model) loadKConPage(c config.KafkaConnectConfig) tea.Cmd {
kca := kcadmin.New(http.DefaultClient, &c)
var cmd tea.Cmd
m.active, cmd = kcon_page.New(m.navBack, kca, c.Name)
return cmd
}
func New(cluster *config.Cluster) (*Model, tea.Cmd) {
m := Model{}
kconsPage, cmd := kcon_clusters_page.New(cluster, m.loadKConPage)
m.kconsPage = kconsPage
m.active = kconsPage
return &m, cmd
}
package loading_tab
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"ktea/kontext"
"ktea/ui"
"ktea/ui/components/notifier"
)
type Model struct {
notifier *notifier.Model
}
func (m *Model) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
return renderer.Render(lipgloss.NewStyle().
Width(ktx.WindowWidth).
Height(ktx.AvailableHeight).
AlignHorizontal(lipgloss.Center).
AlignVertical(lipgloss.Center).
Render(m.notifier.View(ktx, renderer)))
}
func (m *Model) Update(msg tea.Msg) tea.Cmd {
return m.notifier.Update(msg)
}
func New() (*Model, tea.Cmd) {
n := notifier.New()
cmd := n.SpinWithRocketMsg("loading")
return &Model{n}, cmd
}
package sr_tab
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"ktea/kontext"
"ktea/sradmin"
"ktea/ui"
"ktea/ui/clipper"
"ktea/ui/components/statusbar"
"ktea/ui/pages/create_schema_page"
"ktea/ui/pages/nav"
"ktea/ui/pages/schema_details_page"
"ktea/ui/pages/subjects_page"
)
type Model struct {
active nav.Page
statusbar *statusbar.Model
ktx *kontext.ProgramKtx
compLister sradmin.GlobalCompatibilityLister
subjectsPage *subjects_page.Model
schemaDetailsPage *schema_details_page.Model
srClient sradmin.Client
}
func (m *Model) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
statusBarView := m.statusbar.View(ktx, renderer)
return ui.JoinVertical(
lipgloss.Top,
statusBarView,
m.active.View(ktx, renderer),
)
}
func (m *Model) Update(msg tea.Msg) tea.Cmd {
var cmds []tea.Cmd
switch msg := msg.(type) {
case sradmin.SubjectsListedMsg:
return m.subjectsPage.Update(msg)
case nav.LoadCreateSubjectPageMsg:
createPage, cmd := create_schema_page.New(m.srClient, m.ktx)
cmds = append(cmds, cmd)
m.active = createPage
case nav.LoadSubjectsPageMsg:
if m.subjectsPage == nil || msg.Refresh && m.active != m.subjectsPage {
var cmd tea.Cmd
m.subjectsPage, cmd = subjects_page.New(m.srClient)
cmds = append(cmds, cmd)
}
m.active = m.subjectsPage
case nav.LoadSchemaDetailsPageMsg:
var cmd tea.Cmd
m.schemaDetailsPage, cmd = schema_details_page.New(m.srClient, m.srClient, msg.Subject, clipper.New())
m.active = m.schemaDetailsPage
cmds = append(cmds, cmd)
}
m.statusbar = statusbar.New(m.active)
cmds = append(cmds, m.active.Update(msg))
return tea.Batch(cmds...)
}
func New(
srClient sradmin.Client,
ktx *kontext.ProgramKtx,
) (*Model, tea.Cmd) {
subjectsPage, cmd := subjects_page.New(srClient)
model := Model{active: subjectsPage}
model.subjectsPage = subjectsPage
model.statusbar = statusbar.New(subjectsPage)
model.srClient = srClient
model.ktx = ktx
return &model, cmd
}
package topics_tab
import (
"context"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/log"
"ktea/kadmin"
"ktea/kontext"
"ktea/ui"
"ktea/ui/clipper"
"ktea/ui/components/statusbar"
"ktea/ui/pages/configs_page"
"ktea/ui/pages/consumption_form_page"
"ktea/ui/pages/consumption_page"
"ktea/ui/pages/create_topic_page"
"ktea/ui/pages/nav"
"ktea/ui/pages/publish_page"
"ktea/ui/pages/record_details_page"
"ktea/ui/pages/topics_page"
"reflect"
)
type Model struct {
active nav.Page
topicsPage *topics_page.Model
statusbar *statusbar.Model
ka kadmin.Kadmin
ktx *kontext.ProgramKtx
consumptionPage nav.Page
recordDetailsPage nav.Page
ctx context.Context
}
func (m *Model) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
var views []string
if m.statusbar != nil {
views = append(views, m.statusbar.View(ktx, renderer))
}
views = append(views, m.active.View(ktx, renderer))
return ui.JoinVertical(lipgloss.Top, views...)
}
func (m *Model) Update(msg tea.Msg) tea.Cmd {
log.Debug("Received Update", "msg", reflect.TypeOf(msg))
var cmds []tea.Cmd
switch msg := msg.(type) {
case kadmin.TopicsListedMsg:
// Make sure TopicsListedMsg is explicitly captured and
// properly propagated in the case when cgroupsPage
//isn't focused anymore.
return m.topicsPage.Update(msg)
case nav.LoadTopicsPageMsg:
if msg.Refresh {
cmds = append(cmds, m.topicsPage.Refresh())
}
m.active = m.topicsPage
case nav.LoadConsumptionFormPageMsg:
if msg.ReadDetails != nil {
m.active = consumption_form_page.NewWithDetails(msg.ReadDetails, msg.Topic, m.ktx)
} else {
m.active = consumption_form_page.New(msg.Topic, m.ktx)
}
case nav.LoadRecordDetailPageMsg:
m.active = record_details_page.New(msg.Record, msg.TopicName, clipper.New(), m.ktx)
m.recordDetailsPage = m.active
case nav.LoadTopicConfigPageMsg:
page, cmd := configs_page.New(m.ka, m.ka, m.topicsPage.SelectedTopicName())
cmds = append(cmds, cmd)
m.active = page
case nav.LoadCreateTopicPageMsg:
log.Debug("Loading create topic page")
m.active = create_topic_page.New(m.ka)
case nav.LoadPublishPageMsg:
m.active = publish_page.New(m.ka, msg.Topic)
case nav.LoadCachedConsumptionPageMsg:
m.active = m.consumptionPage
case nav.LoadConsumptionPageMsg:
var cmd tea.Cmd
m.active, cmd = consumption_page.New(m.ka, msg.ReadDetails, msg.Topic)
m.consumptionPage = m.active
cmds = append(cmds, cmd)
case nav.LoadLiveConsumePageMsg:
var cmd tea.Cmd
readDetails := kadmin.ReadDetails{
TopicName: msg.Topic.Name,
PartitionToRead: msg.Topic.Partitions(),
StartPoint: kadmin.Live,
Limit: 500,
Filter: nil,
}
m.active, cmd = consumption_page.New(m.ka, readDetails, msg.Topic)
m.consumptionPage = m.active
cmds = append(cmds, cmd)
}
if cmd := m.active.Update(msg); cmd != nil {
cmds = append(cmds, cmd)
}
// always recreate the statusbar in case the active page might have changed
m.statusbar = statusbar.New(m.active)
return tea.Batch(cmds...)
}
func New(ktx *kontext.ProgramKtx, ka kadmin.Kadmin) (*Model, tea.Cmd) {
var cmd tea.Cmd
listTopicView, cmd := topics_page.New(ka, ka)
model := &Model{}
model.ka = ka
model.ktx = ktx
model.active = listTopicView
model.topicsPage = listTopicView
model.statusbar = statusbar.New(model.active)
return model, cmd
}
package ui
import tea "github.com/charmbracelet/bubbletea"
// NavBack logically navigates back
type NavBack func() tea.Cmd
type NavBackMockCalledMsg struct{}
// NavBackMock mocks NavBack by returning a tea.Cmd that returns a NavBackMockCalledMsg
func NavBackMock() tea.Cmd {
return func() tea.Msg {
return NavBackMockCalledMsg{}
}
}