package main
import (
"flag"
"ktea/config"
"ktea/kadmin"
"ktea/kcadmin"
"ktea/kontext"
"ktea/sradmin"
"ktea/ui"
"ktea/ui/components/statusbar"
"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}
cgroupsTab = tab.Tab{Title: "Consumer Groups", Label: cgroupsTabLbl}
schemaRegTab = tab.Tab{Title: "Schema Registry", Label: schemaRegTabLbl}
kconnectTab = tab.Tab{Title: "Kafka Connect", Label: kconnectTabLbl}
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
statusbar *statusbar.Model
}
// 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
case tea.KeyF1:
m.statusbar.ToggleShortcuts()
return m, nil
}
// 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)
return m, tea.Batch(cmds...)
}
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.RegisterConfig(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 {
m.statusbar = statusbar.New()
tCtrl, cmd := clusters_tab.New(m.ktx, kadmin.CheckKafkaConnectivity, sradmin.CheckSchemaRegistryConn, m.statusbar)
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, m.statusbar)
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 {
if ka, err := m.kaInstantiator(cluster); 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
m.statusbar = statusbar.New()
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.statusbar)
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)
m.topicsTabCtrl, cmd = topics_tab.New(m.ktx, m.ka, m.statusbar)
cmds = append(cmds, cmd)
m.cgroupsTabCtrl, cmd = cgroups_tab.New(m.ka, m.ka, m.ka, m.statusbar)
cmds = append(cmds, cmd)
m.clustersTabCtrl, cmd = clusters_tab.New(m.ktx, kadmin.CheckKafkaConnectivity, sradmin.CheckSchemaRegistryConn, m.statusbar)
cmds = append(cmds, cmd)
m.kconTabCtrl, cmd = kcon_tab.New(m.ktx.Config().ActiveCluster(), m.statusbar)
cmds = append(cmds, cmd)
if m.ktx.Config().ActiveCluster().HasSchemaRegistry() {
m.schemaRegistryTabCtrl, cmd = sr_tab.New(m.sra, m.ktx, m.statusbar)
cmds = append(cmds, cmd)
}
m.tabCtrl = m.topicsTabCtrl
return tea.Batch(cmds...)
}
}
func NewModel(
disableNerdFonts *bool,
kai kadmin.Instantiator,
configIO config.IO,
) *Model {
return &Model{
kaInstantiator: kai,
ktx: kontext.New(disableNerdFonts),
configIO: configIO,
}
}
func main() {
var (
debugParam bool
plainFontsParam bool
)
flag.BoolVar(&debugParam, "debug", false, "enable debug")
flag.BoolVar(&plainFontsParam, "plain-fonts", false, "disable NerdFonts (if you see weird icons)")
flag.Parse()
plainFontParamSet := false
flag.Visit(func(f *flag.Flag) {
if f.Name == "plain-fonts" {
plainFontParamSet = true
}
})
var disableNerdFonts *bool
if plainFontParamSet {
disableNerdFonts = &plainFontsParam
}
p := tea.NewProgram(
NewModel(
disableNerdFonts,
kadmin.SaramaInstantiator(),
config.NewDefaultIO(),
),
tea.WithAltScreen(),
)
if debugParam {
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"
"os"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/log"
)
type AuthMethod string
const (
AuthMethodNone AuthMethod = "NONE"
AuthMethodSASLPlaintext AuthMethod = "SASL_PLAINTEXT"
AuthMethodSASLSCRAMSHA256 AuthMethod = "SASL_SCRAM_SHA256"
AuthMethodSASLSCRAMSHA512 AuthMethod = "SASL_SCRAM_SHA512"
)
type SASLConfig struct {
Username string `yaml:"username"`
Password string `yaml:"password"`
AuthMethod AuthMethod `yaml:"authMethod"`
}
type SchemaRegistryConfig struct {
Url string `yaml:"url"`
Username string `yaml:"username"`
Password string `yaml:"password"`
TLSConfig TLSConfig `yaml:"tls"`
}
type TLSConfig struct {
Enable bool `yaml:"enable"`
SkipVerify bool `yaml:"skipVerify"`
CACertPath string `yaml:"caCertPath"`
ClientCert string `yaml:"clientCert"`
ClientKey string `yaml:"clientKey"`
}
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"`
// Schema registry is optional, hence can be nil
SchemaRegistry *SchemaRegistryConfig `yaml:"schemaRegistry"`
TLSConfig TLSConfig `yaml:"tls"`
// Kafka Connect clusters are optional, hence can be empty
KafkaConnectClusters []KafkaConnectConfig `yaml:"kafkaConnectClusters"`
}
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:"-"`
PlainFonts bool `yaml:"plainFonts"`
}
func (c *Config) HasClusters() bool {
return len(c.Clusters) > 0
}
type SchemaRegistryDetails struct {
Url string
Username string
Password string
TLSConfig TLSConfig
}
type KafkaConnectClusterDetails struct {
Name string
Url string
Username *string
Password *string
}
type RegistrationDetails struct {
Name string
Color string
Host string
AuthMethod AuthMethod
TLSConfig TLSConfig
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},
TLSConfig: details.TLSConfig,
}
if details.AuthMethod == AuthMethodSASLPlaintext || details.AuthMethod == AuthMethodSASLSCRAMSHA256 || details.AuthMethod == AuthMethodSASLSCRAMSHA512 {
cluster.SASLConfig = SASLConfig{
Username: details.Username,
Password: details.Password,
AuthMethod: details.AuthMethod,
}
} else if details.AuthMethod == AuthMethodNone {
cluster.SASLConfig = SASLConfig{
AuthMethod: details.AuthMethod,
}
}
if details.SchemaRegistry != nil {
cluster.SchemaRegistry = &SchemaRegistryConfig{
Url: details.SchemaRegistry.Url,
Username: details.SchemaRegistry.Username,
Password: details.SchemaRegistry.Password,
TLSConfig: details.SchemaRegistry.TLSConfig,
}
}
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 (
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"os"
"time"
)
type CertValidationFunc func(certFile string) error
func CertValidator(certFile string) error {
if _, err := os.Stat(certFile); errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("CA Certificate file does not exist at path: %s", certFile)
}
data, err := os.ReadFile(certFile)
if err != nil {
return fmt.Errorf("CA Certificate file could not be read at path: %s", certFile)
}
block, _ := pem.Decode(data)
if block == nil || block.Type != "CERTIFICATE" {
return errors.New("file does not contain a PEM-encoded certificate")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return fmt.Errorf("invalid X.509 certificate: %w", err)
}
now := time.Now()
if now.Before(cert.NotBefore) || now.After(cert.NotAfter) {
return fmt.Errorf(
"certificate is not valid at current time (valid from %s to %s)",
cert.NotBefore, cert.NotAfter,
)
}
return nil
}
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
TLSConfig *TLSConfig
}
type TLSConfig struct {
Enable bool
SkipVerify bool
CACertPath string
}
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(cluster *config.Cluster) (Kadmin, error)
type ConnChecker func(cluster *config.Cluster) tea.Msg
func SaramaInstantiator() Instantiator {
return func(cluster *config.Cluster) (Kadmin, error) {
return NewSaramaKadmin(cluster)
}
}
package kadmin
import (
"math"
"sync"
"github.com/IBM/sarama"
tea "github.com/charmbracelet/bubbletea"
)
const (
ErrorValue int64 = math.MinInt64
)
type OffsetLister interface {
ListOffsets(group string) tea.Msg
}
type TopicPartitionOffset struct {
Topic string
Partition int32
Offset int64
HighWaterMark int64
Lag 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
return
}
totalPartitions := 0
for _, m := range listResult.Blocks {
totalPartitions += len(m)
}
topicPartitionOffsets := make([]TopicPartitionOffset, 0, totalPartitions)
var mu sync.Mutex
var wg sync.WaitGroup
for t, m := range listResult.Blocks {
for p, block := range m {
wg.Go(
func() {
hwm, err := ka.client.GetOffset(t, p, sarama.OffsetNewest)
var lag int64
if err != nil {
hwm = ErrorValue
lag = ErrorValue
} else {
lag = hwm - block.Offset
}
mu.Lock()
topicPartitionOffsets = append(topicPartitionOffsets, TopicPartitionOffset{
Topic: t,
Partition: p,
Offset: block.Offset,
HighWaterMark: hwm,
Lag: lag,
})
mu.Unlock()
},
)
}
}
wg.Wait()
offsetsChan <- topicPartitionOffsets
}
package kadmin
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"encoding/xml"
"ktea/serdes"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"unicode/utf8"
"github.com/charmbracelet/log"
"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 int64
const (
Beginning StartPoint = iota
MostRecent
Today
Yesterday
Last7Days
Live
)
func (p *StartPoint) time() int64 {
switch *p {
case Beginning, MostRecent, Live:
return sarama.OffsetOldest
case Today:
t := time.Now()
startOfDay := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
return startOfDay.UnixMilli()
case Yesterday:
t := time.Now().AddDate(0, 0, -1)
startOfDay := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
return startOfDay.UnixMilli()
case Last7Days:
t := time.Now().AddDate(0, 0, -7)
startOfDay := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
return startOfDay.UnixMilli()
}
return int64(*p)
}
type RecordReader interface {
ReadRecords(ctx context.Context, rd ReadDetails) tea.Msg
}
type ReadingStartedMsg struct {
ConsumerRecord chan ConsumerRecord
EmptyTopic chan bool
// NoRecordsFound indicates that there are no records in any of the selected partitions
// for the given filter criteria.
NoRecordsFound chan bool
Err chan error
CancelFunc context.CancelFunc
}
func (m *ReadingStartedMsg) AwaitRecord() tea.Msg {
select {
case record, ok := <-m.ConsumerRecord:
if !ok {
return ConsumptionEndedMsg{}
}
return ConsumerRecordReceived{
Records: []ConsumerRecord{record},
consumerRecord: m.ConsumerRecord,
emptyTopic: m.EmptyTopic,
noRecordsFound: m.NoRecordsFound,
err: m.Err,
cancelFunc: m.CancelFunc,
}
case empty := <-m.EmptyTopic:
if empty {
return EmptyTopicMsg{}
}
return nil
case noRecords := <-m.NoRecordsFound:
if noRecords {
return NoRecordsFound{
consumerRecord: m.ConsumerRecord,
emptyTopic: m.EmptyTopic,
noRecordsFound: m.NoRecordsFound,
err: m.Err,
cancelFunc: m.CancelFunc,
}
}
return nil
case err := <-m.Err:
return err
}
}
func (m *ReadingStartedMsg) shutdown() {
m.CancelFunc()
close(m.ConsumerRecord)
close(m.Err)
close(m.EmptyTopic)
close(m.NoRecordsFound)
}
type ConsumerRecordReceived struct {
Records []ConsumerRecord
consumerRecord chan ConsumerRecord
emptyTopic chan bool
noRecordsFound chan bool
err chan error
cancelFunc context.CancelFunc
}
func (m *ConsumerRecordReceived) AwaitNextRecord() tea.Msg {
log.Debug("awaiting next record")
select {
case record, ok := <-m.consumerRecord:
if !ok {
return ConsumptionEndedMsg{}
}
records := []ConsumerRecord{record}
timeout := time.After(50 * time.Millisecond)
for {
select {
case r := <-m.consumerRecord:
records = append(records, r)
case <-timeout:
return ConsumerRecordReceived{
Records: records,
consumerRecord: m.consumerRecord,
emptyTopic: m.emptyTopic,
noRecordsFound: m.noRecordsFound,
err: m.err,
cancelFunc: m.cancelFunc,
}
}
}
case emptyTopic := <-m.emptyTopic:
if emptyTopic {
return EmptyTopicMsg{}
}
return nil
case err := <-m.err:
return err
}
}
type EmptyTopicMsg struct {
}
type NoRecordsFound struct {
consumerRecord chan ConsumerRecord
emptyTopic chan bool
noRecordsFound chan bool
err chan error
cancelFunc context.CancelFunc
}
type ConsumptionEndedMsg struct{}
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 NewDefaultReadDetails(topic *ListedTopic) ReadDetails {
return ReadDetails{
TopicName: topic.Name,
PartitionToRead: topic.Partitions(),
StartPoint: MostRecent,
Limit: 500,
Filter: &Filter{},
}
}
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
}
func (record *ConsumerRecord) PayloadType() string {
// schema is not empty, so it's Avro
if record.Payload.Schema != "" {
return "Avro"
}
// value is empty, so it's plain text'
value := strings.TrimSpace(record.Payload.Value)
if value == "" {
return "Plain Text"
}
// value is a valid json, so it's a json'
if json.Valid([]byte(value)) {
return "Plain Json"
}
// value is a valid xml, so it's a xml'
if isValidXML([]byte(value)) {
return "Plain XML"
}
// default value is plain text
return "Plain Text"
}
func isValidXML(data []byte) bool {
err := xml.Unmarshal(data, new(interface{}))
return err == nil
}
type offsets struct {
start int64
// most recent available, unused, offset
end int64
}
func (o *offsets) newest() int64 {
return o.end - 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, 1),
EmptyTopic: make(chan bool, 1),
NoRecordsFound: make(chan bool, 1),
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 {
startedMsg.shutdown()
}
var (
msgCount atomic.Int64
wg sync.WaitGroup
offsets map[int]offsets
)
offsets, err = ka.fetchOffsets(rd.PartitionToRead, rd.TopicName, rd.StartPoint)
if err != nil {
startedMsg.Err <- err
close(startedMsg.ConsumerRecord)
close(startedMsg.Err)
cancelFunc()
}
if noRecordsFound(offsets) {
cancelFunc()
startedMsg.NoRecordsFound <- true
return
}
emptyTopic := true
log.Debug("Starting to read records",
"partition", rd.PartitionToRead,
"offsets", offsets)
for _, p := range rd.PartitionToRead {
// if there is no data in the partition, we don't need to read it unless live consumption is requested
partition := p
if offsets[partition].end != offsets[partition].start || rd.StartPoint == Live {
emptyTopic = false
wg.Go(func() {
readingOffsets := ka.determineReadingOffsets(rd, offsets[partition])
log.Debug("Reading offsets determined",
"topic", rd.TopicName,
"partition", partition,
"start", readingOffsets.start,
"end", readingOffsets.end,
)
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) {
// For MostRecent + filter, check if we've reached the end
if rd.StartPoint == MostRecent && msg.Offset >= readingOffsets.end {
return
}
continue
}
}
consumerRecord := ConsumerRecord{
Key: key,
Payload: desData,
Err: err,
Partition: int64(msg.Partition),
Offset: msg.Offset,
Headers: headers,
Timestamp: msg.Timestamp,
}
if msgCount.Add(1) >= int64(rd.Limit) {
select {
case startedMsg.ConsumerRecord <- consumerRecord:
case <-ctx.Done():
}
// Now that the last message is sent (or we're exiting), return.
return
}
select {
case startedMsg.ConsumerRecord <- consumerRecord:
case <-ctx.Done():
return
}
if msg.Offset == readingOffsets.end && rd.StartPoint != Live {
// For MostRecent + filter, exit when we've consumed all available records
if rd.StartPoint == MostRecent && rd.Filter != nil {
// Continue only if we haven't reached the newest offset yet
if msg.Offset < readingOffsets.end {
continue
}
}
return
}
}
}
})
}
}
if emptyTopic {
startedMsg.EmptyTopic <- true
}
go func() {
wg.Wait()
time.Sleep(50 * time.Millisecond)
startedMsg.shutdown()
}()
}
func noRecordsFound(offsets map[int]offsets) bool {
for _, off := range offsets {
// -1 indicates that no records exist for the requested offsets
if off.start != -1 {
return false
}
}
return true
}
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.end,
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(
offsets,
numberOfRecordsPerPart,
)
} 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.start {
startOffset = offsets.start
}
return startOffset, endOffset
}
func (ka *SaramaKafkaAdmin) determineOffsetsFromBeginning(
offsets offsets,
numberOfRecordsPerPart int64,
) (int64, int64) {
var (
startOffset int64
endOffset int64
)
startOffset = offsets.start
if (offsets.start + numberOfRecordsPerPart) < offsets.newest() {
endOffset = startOffset + numberOfRecordsPerPart - 1
} else {
endOffset = offsets.newest()
}
return startOffset, endOffset
}
func (ka *SaramaKafkaAdmin) fetchOffsets(
partitions []int,
topicName string,
startPoint StartPoint,
) (map[int]offsets, error) {
offsetsByPartition := make(map[int]offsets)
var wg sync.WaitGroup
var mu sync.Mutex
errorsChan := make(chan error, len(partitions))
for _, p := range partitions {
partition := p
log.Debug("Fetching offsets", "topic", topicName, "partition", partition)
wg.Go(func() {
startOffset, err := ka.client.GetOffset(
topicName,
int32(partition),
startPoint.time(),
)
if err != nil {
errorsChan <- err
return
}
log.Debug(
"Fetched start offset",
"topic", topicName,
"partition", partition,
"startOffset", startOffset,
)
endOffset, err := ka.client.GetOffset(
topicName,
int32(partition),
sarama.OffsetNewest,
)
if err != nil {
errorsChan <- err
return
}
log.Debug("Fetched end offset",
"topic", topicName,
"partition", partition,
"endOffset", endOffset)
mu.Lock()
offsetsByPartition[partition] = offsets{
startOffset,
endOffset,
}
mu.Unlock()
})
}
wg.Wait()
select {
case err := <-errorsChan:
return nil, err
default:
return offsetsByPartition, nil
}
}
package kadmin
import (
"crypto/sha256"
"crypto/sha512"
"crypto/tls"
"crypto/x509"
"fmt"
"ktea/config"
"ktea/sradmin"
"os"
"time"
"github.com/IBM/sarama"
"github.com/burdiyan/kafkautil"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/log"
"github.com/xdg-go/scram"
)
var (
SHA256 scram.HashGeneratorFcn = sha256.New
SHA512 scram.HashGeneratorFcn = sha512.New
)
type XDGSCRAMClient struct {
*scram.Client
*scram.ClientConversation
scram.HashGeneratorFcn
}
func (x *XDGSCRAMClient) Begin(userName, password, authzID string) (err error) {
x.Client, err = x.HashGeneratorFcn.NewClient(userName, password, authzID)
if err != nil {
return err
}
x.ClientConversation = x.Client.NewConversation()
return nil
}
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 ToSaramaCfg(cluster *config.Cluster) *sarama.Config {
cfg := sarama.NewConfig()
cfg.Producer.Return.Successes = true
cfg.Producer.RequiredAcks = sarama.WaitForAll
cfg.Producer.Partitioner = kafkautil.NewJVMCompatiblePartitioner
cfg.Consumer.Offsets.Initial = sarama.OffsetOldest
if cluster.TLSConfig.Enable {
tlsConfig := &tls.Config{
InsecureSkipVerify: cluster.TLSConfig.SkipVerify,
}
if cluster.TLSConfig.CACertPath != "" {
caCert, err := os.ReadFile(cluster.TLSConfig.CACertPath)
if err != nil {
panic(fmt.Sprintf("Unable to read CA cert file: %v", err))
}
caCertPool := x509.NewCertPool()
if !caCertPool.AppendCertsFromPEM(caCert) {
panic("Failed to parse CA certificate")
}
tlsConfig.RootCAs = caCertPool
}
if cluster.TLSConfig.ClientCert != "" && cluster.TLSConfig.ClientKey != "" {
cert, err := tls.LoadX509KeyPair(cluster.TLSConfig.ClientCert, cluster.TLSConfig.ClientKey)
if err != nil {
panic(fmt.Sprintf("Unable to load client certificate: %v", err))
}
tlsConfig.Certificates = []tls.Certificate{cert}
}
cfg.Net.TLS.Enable = true
cfg.Net.TLS.Config = tlsConfig
}
if cluster.SASLConfig.AuthMethod == config.AuthMethodSASLPlaintext {
cfg.Net.SASL.Enable = true
cfg.Net.SASL.Mechanism = sarama.SASLTypePlaintext
cfg.Net.SASL.User = cluster.SASLConfig.Username
cfg.Net.SASL.Password = cluster.SASLConfig.Password
} else if cluster.SASLConfig.AuthMethod == config.AuthMethodSASLSCRAMSHA256 {
cfg.Net.SASL.Enable = true
cfg.Net.SASL.Mechanism = sarama.SASLTypeSCRAMSHA256
cfg.Net.SASL.User = cluster.SASLConfig.Username
cfg.Net.SASL.Password = cluster.SASLConfig.Password
cfg.Net.SASL.SCRAMClientGeneratorFunc = func() sarama.SCRAMClient {
return &XDGSCRAMClient{HashGeneratorFcn: SHA256}
}
} else if cluster.SASLConfig.AuthMethod == config.AuthMethodSASLSCRAMSHA512 {
cfg.Net.SASL.Enable = true
cfg.Net.SASL.Mechanism = sarama.SASLTypeSCRAMSHA512
cfg.Net.SASL.User = cluster.SASLConfig.Username
cfg.Net.SASL.Password = cluster.SASLConfig.Password
cfg.Net.SASL.SCRAMClientGeneratorFunc = func() sarama.SCRAMClient {
return &XDGSCRAMClient{HashGeneratorFcn: SHA512}
}
}
cfg.Net.DialTimeout = 5 * time.Second
cfg.Net.ReadTimeout = 5 * time.Second
cfg.Net.WriteTimeout = 5 * time.Second
return cfg
}
func NewSaramaKadmin(cluster *config.Cluster) (Kadmin, error) {
cfg := ToSaramaCfg(cluster)
client, err := sarama.NewClient(cluster.BootstrapServers, cfg)
if err != nil {
return nil, err
}
admin, err := sarama.NewClusterAdmin(cluster.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: cluster.BootstrapServers,
producer: producer,
config: cfg,
}, nil
}
func CheckKafkaConnectivity(cluster *config.Cluster) tea.Msg {
connectedChan := make(chan bool)
errChan := make(chan error)
cfg := ToSaramaCfg(cluster)
go doCheckConnectivity(cluster.BootstrapServers, cfg, errChan, connectedChan)
return ConnCheckStartedMsg{
Cluster: cluster,
Connected: connectedChan,
Err: errChan,
}
}
func doCheckConnectivity(servers []string, config *sarama.Config, errChan chan error, connectedChan chan bool) {
MaybeIntroduceLatency()
c, err := sarama.NewClient(servers, 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 (
"sync"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/log"
)
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
Cleanup string
}
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
wg = sync.WaitGroup{}
mu = sync.Mutex{}
)
for topic, t := range listResult {
wg.Go(func() {
msg := ka.ListConfigs(topic).(TopicConfigListingStartedMsg)
var configs map[string]string
switch c := msg.AwaitCompletion().(type) {
case TopicConfigListingErrorMsg:
log.Errorf("error listing configs for topic %s: %v", topic, c.Err)
errChan <- c.Err
return
case TopicConfigsListedMsg:
configs = c.Configs
}
cleanupPolicy := "unknown"
if policy, ok := configs["cleanup.policy"]; ok {
cleanupPolicy = policy
}
mu.Lock()
topics = append(topics, ListedTopic{
topic,
int(t.NumPartitions),
int(t.ReplicationFactor),
cleanupPolicy,
})
mu.Unlock()
})
}
wg.Wait()
topicsChan <- topics
close(topicsChan)
}
package kadmin
import (
"github.com/IBM/sarama"
"github.com/burdiyan/kafkautil"
tea "github.com/charmbracelet/bubbletea"
"time"
)
type Publisher interface {
PublishRecord(p *ProducerRecord) PublicationStartedMsg
}
type ProducerRecord struct {
Key string
Value []byte
Topic string
Partition *int
Headers map[string]string
Timestamp time.Time
}
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,
Timestamp: p.Timestamp,
})
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
cmdLineFlags
WindowWidth int
WindowHeight int
AvailableHeight int
}
type cmdLineFlags struct {
disableNerdFonts *bool
}
func (k *ProgramKtx) HeightUsed(height int) {
if k.AvailableHeight < height {
k.AvailableHeight -= k.AvailableHeight
} else {
k.AvailableHeight -= height
}
}
func (k *ProgramKtx) AvailableTableHeight() int {
// 2 for top and bottom border + 1 for top extra padding
return k.AvailableHeight - 3
}
func (k *ProgramKtx) Config() *config.Config {
return k.config
}
func (k *ProgramKtx) RegisterConfig(c *config.Config) {
k.config = c
if k.disableNerdFonts != nil {
k.config.PlainFonts = *k.disableNerdFonts
}
}
func New(disableNerdFonts *bool) *ProgramKtx {
return &ProgramKtx{
cmdLineFlags: cmdLineFlags{
disableNerdFonts: disableNerdFonts,
},
}
}
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 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()
}
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 (
"crypto/tls"
"crypto/x509"
"encoding/base64"
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/riferrei/srclient"
"ktea/config"
"net/http"
"os"
"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))
tlsConfig := &tls.Config{
InsecureSkipVerify: registry.TLSConfig.SkipVerify,
}
if registry.TLSConfig.CACertPath != "" {
caCert, err := os.ReadFile(registry.TLSConfig.CACertPath)
if err != nil {
panic(fmt.Sprintf("Unable to read CA cert file: %v", err))
}
caCertPool := x509.NewCertPool()
if !caCertPool.AppendCertsFromPEM(caCert) {
panic("Failed to parse CA certificate")
}
tlsConfig.RootCAs = caCertPool
}
if registry.TLSConfig.ClientCert != "" && registry.TLSConfig.ClientKey != "" {
cert, err := tls.LoadX509KeyPair(registry.TLSConfig.ClientCert, registry.TLSConfig.ClientKey)
if err != nil {
panic(fmt.Sprintf("Unable to load client certificate: %v", err))
}
tlsConfig.Certificates = []tls.Certificate{cert}
}
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
TLSClientConfig: tlsConfig,
}
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"
"slices"
"sync"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/log"
)
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
name := subj.Name
idx := i
if !subj.Deleted {
wg.Go(func() {
versions, err := s.client.GetSchemaVersions(name)
if err != nil {
errs <- fmt.Errorf("get versions %s: %w", name, err)
return
}
versionResults[idx] = versions
})
}
wg.Go(func() {
comp, err := s.client.GetCompatibilityLevel(name, true)
if err != nil {
errs <- fmt.Errorf("get compatibility %s: %w", name, err)
return
}
compResults[idx] = comp.String()
})
}
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 (
"strconv"
"sync"
tea "github.com/charmbracelet/bubbletea"
)
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
for _, version := range versions {
v := version
wg.Go(func() {
schema, err := s.client.GetSchemaByVersion(subject, v)
if err == nil {
schemaChan <- Schema{
Id: strconv.Itoa(schema.ID()),
Value: schema.Schema(),
Version: version,
}
} else {
schemaChan <- Schema{
Err: err,
Version: v,
}
}
})
}
go func() {
wg.Wait()
close(schemaChan)
}()
return SchemaListingStarted{
schemaChan: schemaChan, versionCount: len(versions),
}
}
package styles
import (
"fmt"
"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"
const ColorMidGrey = "#353533"
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(" ]")
}
}
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
Active lipgloss.Style
Help 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(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: "┴",
}
helpBorder := lipgloss.Border{
Top: " ",
Bottom: "─",
Left: " ",
Right: " ",
TopLeft: " ",
TopRight: " ",
BottomLeft: "─",
BottomRight: "─",
}
tabStyle.Tab = lipgloss.NewStyle().
Padding(0, 1).
Foreground(lipgloss.Color("#AAAAAA")).
Border(tabBorder)
tabStyle.Active = lipgloss.NewStyle().
Padding(0, 1).
Border(activeTabBorder).
Foreground(lipgloss.Color(ColorPink)).
Bold(true)
tabStyle.Help = lipgloss.NewStyle().
Padding(0, 1).
Border(helpBorder).
Foreground(lipgloss.Color(ColorWhite)).
Bold(true)
Tab = tabStyle
}
{
statusBarStyle := StatusBarStyle{}
statusBarStyle.style = lipgloss.NewStyle().
Background(lipgloss.Color("#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).
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
msgByNotificationHandler map[reflect.Type]NotificationHandler[any]
tag string
}
func (n *NotifierCmdBar) IsFocussed() bool {
return n.active && n.Notifier.HasPriority()
}
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.msgByNotificationHandler[msgType]; ok {
active, cmd := notification(msg, n.Notifier)
n.active = active
return n.active, nil, cmd
}
return n.active, msg, nil
}
// BindNotificationHandler binds a NotificationHandler for a specific message type T to the NotifierCmdBar
func BindNotificationHandler[T any](
bar *NotifierCmdBar,
notificationHandler NotificationHandler[T],
) *NotifierCmdBar {
msgType := reflect.TypeOf((*T)(nil)).Elem()
bar.msgByNotificationHandler[msgType] = WrapNotification(notificationHandler)
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,
msgByNotificationHandler: 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 (s *SearchCmdBar) Hide() {
s.state = hidden
}
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.activeIdx]
}
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()) + " " + lipgloss.NewStyle().Bold(true).Render(title)
}
return lipgloss.NewStyle().Bold(true).Render(title)
}
func WithSortSelectedCallback(callback SortSelectedCallback) SortByCmdBarOption {
return func(bar *SortByCmdBar) {
bar.sortSelectedCallback = callback
}
}
func WithInitialSortColumn(column string, direction Direction) SortByCmdBarOption {
return func(bar *SortByCmdBar) {
for i, sort := range bar.sorts {
if sort.Label == column {
bar.selectedIdx = i
bar.activeIdx = i
bar.sorts[i].Direction = direction
return
}
}
}
}
func NewSortByCmdBar(
sorts []SortLabel,
options ...SortByCmdBarOption,
) *SortByCmdBar {
bar := SortByCmdBar{
sorts: sorts,
Active: false,
}
for _, option := range options {
option(&bar)
}
return &bar
}
package cmdbar
import (
"ktea/kontext"
"ktea/ui"
"ktea/ui/components/statusbar"
tea "github.com/charmbracelet/bubbletea"
)
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 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.Hide()
m.deleteCBar.Hide()
}
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
showShortcuts bool
}
type Provider interface {
Shortcuts() []Shortcut
Title() string
}
type UpdateMsg struct {
StatusBar Provider
}
type Shortcut struct {
Name string
Keybinding string
}
func (m *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)
clusterColor := ktx.Config().ActiveCluster().Color
rightSeparator := "\uE0B0"
if ktx.Config().PlainFonts {
rightSeparator = ""
}
leftSeparator := "\uE0B6"
if ktx.Config().PlainFonts {
leftSeparator = ""
}
clusterRight := renderRune(rightSeparator, clusterColor, styles.ColorPink)
clusterLeft := renderRune(leftSeparator, clusterColor, "")
activeCluster = clusterLeft + activeCluster + clusterRight
}
indicator := styles.Statusbar.Indicator.Render(m.provider.Title())
var shortCuts string
if m.showShortcuts {
shortcuts := m.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...)
} else {
shortCuts = ""
}
endSeparator := "\uE0B4"
if ktx.Config().PlainFonts {
endSeparator = ""
}
indicator += renderRune(endSeparator, styles.ColorPink, styles.ColorMidGrey)
leftover := ktx.WindowWidth - lg.Width(activeCluster) - lg.Width(indicator)
if !ktx.Config().PlainFonts {
leftover--
}
barView := lg.NewStyle().
MarginTop(1).
MarginBottom(1).
Render(lg.JoinHorizontal(lg.Top,
activeCluster,
indicator,
styles.Statusbar.Spacer.Width(leftover).Render(""),
renderText(endSeparator, styles.ColorMidGrey),
))
if shortCuts == "" {
return renderer.Render(barView)
} else {
return renderer.Render(lg.JoinVertical(lg.Top, styles.Statusbar.Shortcuts.Render(shortCuts), barView))
}
}
func renderRune(symbol string, fg, bg string) string {
return lg.NewStyle().
Foreground(lg.Color(fg)).
Background(lg.Color(bg)).
Render(symbol)
}
func renderText(text, fg string) string {
return lg.NewStyle().
Foreground(lg.Color(fg)).
Render(text)
}
func (m *Model) ToggleShortcuts() {
m.showShortcuts = !m.showShortcuts
}
func (m *Model) SetProvider(provider Provider) {
m.provider = provider
}
func New() *Model {
return &Model{showShortcuts: false}
}
package tab
import (
tea "github.com/charmbracelet/bubbletea"
lg "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
}
var helpTab = Tab{Title: lg.NewStyle().Foreground(lg.Color(styles.ColorYellow)).Render("≪ F1 »") + " help", Label: "help"}
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.Active.Render(t.Title)
} else if m.lastTab(i) {
tab = styles.Tab.Help.Render(t.Title)
} else {
tab = styles.Tab.Tab.Render(t.Title)
}
tabsToRender = append(tabsToRender, tab)
}
renderedTabs := lg.JoinHorizontal(lg.Top, tabsToRender...)
tabLine := strings.Builder{}
leftOverSpace := ctx.WindowWidth - lg.Width(renderedTabs)
for i := 0; i < leftOverSpace; i++ {
tabLine.WriteString("─")
}
s := renderedTabs + tabLine.String()
return renderer.Render(s)
}
func (m *Model) lastTab(i int) bool {
return i == len(m.tabs)-1
}
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()-2 {
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: append(tabs, helpTab),
}
}
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/border"
"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
border *border.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.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.SetWidth(ktx.WindowWidth - 2)
m.table.SetHeight(ktx.AvailableTableHeight())
if m.table.SelectedRow() == nil && len(m.table.Rows()) > 0 {
m.goToTop = true
}
if m.goToTop {
m.table.GotoTop()
m.goToTop = false
}
return ui.JoinVertical(lipgloss.Top, cmdBarView, m.border.View(m.table.View()))
}
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.BindNotificationHandler(
notifierCmdBar,
func(
msg kadmin.ConsumerGroupListingStartedMsg,
m *notifier.Model,
) (bool, tea.Cmd) {
cmd := m.SpinWithLoadingMsg("Loading Consumer Groups")
return true, cmd
},
)
cmdbar.BindNotificationHandler(
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.BindNotificationHandler(
notifierCmdBar,
func(
msg kadmin.ConsumerGroupsListedMsg,
m *notifier.Model,
) (bool, tea.Cmd) {
m.Idle()
return false, nil
},
)
cmdbar.BindNotificationHandler(
notifierCmdBar,
func(
msg kadmin.CGroupDeletionStartedMsg,
m *notifier.Model,
) (bool, tea.Cmd) {
cmd := m.SpinWithLoadingMsg("Deleting Consumer Group")
return true, cmd
},
)
cmdbar.BindNotificationHandler(
notifierCmdBar,
func(
msg kadmin.CGroupDeletedMsg,
m *notifier.Model,
) (bool, tea.Cmd) {
m.Idle()
return false, nil
},
)
cmdbar.BindNotificationHandler(
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.border = border.New(
border.WithInnerPaddingTop(),
border.WithTitleFn(func() string {
return border.KeyValueTitle(
"Total Consumer Groups",
fmt.Sprintf(" %d/%d", len(m.rows), len(m.groups)),
m.tableFocussed,
)
}))
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"
"ktea/kadmin"
"ktea/kontext"
"ktea/styles"
"ktea/ui"
"ktea/ui/components/border"
"ktea/ui/components/cmdbar"
"ktea/ui/components/notifier"
"ktea/ui/components/statusbar"
"ktea/ui/pages/nav"
"reflect"
"slices"
"sort"
"strconv"
"strings"
"github.com/charmbracelet/log"
"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 (
na string = "N/A"
topicFocus tableFocus = 0
offsetFocus tableFocus = 1
stateNoOffsets state = 0
stateOffsetsLoading state = 1
stateOffsetsLoaded state = 2
)
type Model struct {
lister kadmin.OffsetLister
tableFocus tableFocus
topicsTable table.Model
offsetsTable table.Model
totalTable table.Model
offsetsBorder *border.Model
topicsBorder *border.Model
topicsRows []table.Row
offsetRows []table.Row
totalLag int64
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.AvailableTableHeight())
m.topicsTable.SetWidth(int(float64(halfWidth)))
m.topicsTable.SetColumns([]table.Column{
{Title: "Topic Name", Width: int(float64(halfWidth - 2))},
})
m.topicsTable.SetRows(m.topicsRows)
partitionColumnWidth := int(float64(halfWidth-4) * 0.22)
offsetColumnWidth := int(float64(halfWidth-4) * 0.24)
hwmColumnWidth := int(float64(halfWidth-4) * 0.24)
lagColumnWidth := int(float64(halfWidth-4) * 0.22)
m.offsetsTable.SetHeight(ktx.AvailableTableHeight())
m.offsetsTable.SetColumns([]table.Column{
{Title: "Partition", Width: partitionColumnWidth},
{Title: "Offset", Width: offsetColumnWidth},
{Title: "High Watermark", Width: hwmColumnWidth},
{Title: "Lag", Width: lagColumnWidth},
})
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
}
topicsView := m.topicsBorder.View(
renderer.RenderWithStyle(m.topicsTable.View(), topicTableStyle),
)
offsetsView := m.offsetsBorder.View(
renderer.RenderWithStyle(m.offsetsTable.View(), offsetTableStyle),
)
return ui.JoinVertical(lg.Left,
cmdBarView,
lg.JoinHorizontal(
lg.Top,
[]string{
topicsView,
offsetsView,
}...,
),
)
}
type partOffset struct {
partition string
offset int64
hwm int64
lag int64
}
func (partOffset *partOffset) getHwmValue() string {
if partOffset.hwm == kadmin.ErrorValue {
return na
} else {
return humanize.Comma(partOffset.hwm)
}
}
func (partOffset *partOffset) getLagValue() string {
if partOffset.lag == kadmin.ErrorValue {
return na
} else {
return humanize.Comma(partOffset.lag)
}
}
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 "f5":
m.state = stateOffsetsLoading
return func() tea.Msg {
return m.lister.ListOffsets(m.groupName)
}
case "tab":
// only accept when the table is focussed
if !m.cmdBar.IsFocussed() {
if m.tableFocus == topicFocus {
m.tableFocus = offsetFocus
} else {
m.tableFocus = topicFocus
}
}
}
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)
m.offsetsTable.GotoTop()
} else {
m.offsetsTable, cmd = m.offsetsTable.Update(msg)
m.totalTable.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 != "" {
totalLag := int64(0)
m.offsetRows = []table.Row{}
for _, partOffset := range m.topicByPartOffset[selectedTopic] {
totalLag += int64(partOffset.lag)
m.offsetRows = append(m.offsetRows, table.Row{
partOffset.partition,
humanize.Comma(partOffset.offset),
partOffset.getHwmValue(),
partOffset.getLagValue(),
})
}
m.totalLag = totalLag
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 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,
hwm: offset.HighWaterMark,
lag: offset.Lag,
}
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{
{Name: "Go Back", Keybinding: "esc"},
{Name: "Search", Keybinding: "/"},
{Name: "Refresh", Keybinding: "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.BindNotificationHandler(
notifierCmdBar,
func(
msg kadmin.OffsetListingStartedMsg,
m *notifier.Model,
) (bool, tea.Cmd) {
cmd := m.SpinWithLoadingMsg("Loading Offsets")
return true, cmd
},
)
cmdbar.BindNotificationHandler(
notifierCmdBar,
func(
msg kadmin.OffsetListedMsg,
m *notifier.Model,
) (bool, tea.Cmd) {
m.Idle()
return false, m.AutoHideCmd("cgroup")
},
)
model := Model{
lister: lister,
cmdBar: NewCGroupCmdbar[string](
cmdbar.NewSearchCmdBar("Search groups by name"),
notifierCmdBar,
),
tableFocus: topicFocus,
groupName: group,
topicsTable: tt,
offsetsTable: ot,
state: stateOffsetsLoading,
}
model.topicsBorder = border.New(
border.WithInnerPaddingTop(),
border.WithTitleFn(func() string {
return border.KeyValueTitle("Total Topics", fmt.Sprintf(" %d", len(model.topicsRows)), true)
}))
model.offsetsBorder = border.New(border.WithInnerPaddingTop(),
border.WithTitleFn(func() string {
return border.KeyValueTitle("Total Lag", fmt.Sprintf(" %d", model.totalLag), false)
}))
return &model, 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/border"
"ktea/ui/components/cmdbar"
"ktea/ui/components/notifier"
"ktea/ui/components/statusbar"
ktable "ktea/ui/components/table"
"ktea/ui/pages"
"reflect"
"sort"
"strings"
)
type Model struct {
table *table.Model
rows []table.Row
border *border.Model
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.SetRows(m.rows)
m.table.SetWidth(ktx.WindowWidth - 2)
m.table.SetHeight(ktx.AvailableTableHeight())
return ui.JoinVertical(lipgloss.Top, cmdBarView, m.border.View(m.table.View()))
}
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,
) (pages.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.BindNotificationHandler(notifierCmdBar, clusterDeletedHandler)
cmdbar.BindNotificationHandler(notifierCmdBar, activeClusterDeleteErrMsgHandler)
cmdbar.BindNotificationHandler(notifierCmdBar, connCheckStartedHandler)
cmdbar.BindNotificationHandler(notifierCmdBar, connCheckErrHandler)
cmdbar.BindNotificationHandler(notifierCmdBar, connErrHandler)
cmdbar.BindNotificationHandler(notifierCmdBar, connCheckSucceededHandler)
cmdbar.BindNotificationHandler(notifierCmdBar, clusterSwitchedHandler)
model.ktx = ktx
t := ktable.NewDefaultTable()
model.table = &t
model.cmdBar = cmdbar.NewTableCmdsBar(
deleteCmdBar,
searchCmdBar,
notifierCmdBar,
nil,
)
model.border = border.New(
border.WithInnerPaddingTop(),
border.WithTitleFn(func() string {
return border.KeyValueTitle("Total Clusters", fmt.Sprintf(" %d/%d", len(model.rows), len(model.ktx.Config().Clusters)), model.tableFocussed)
}))
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/border"
"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
border *border.Model
cmdBar *CmdBarModel
configs map[string]string
topic string
err error
}
func (m *Model) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
cmdBarView := m.cmdBar.View(ktx, renderer)
// 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.AvailableTableHeight())
m.table.SetRows(m.rows)
m.table.Focus()
return ui.JoinVertical(lipgloss.Top, cmdBarView, m.border.View(m.table.View()))
}
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
m.border = border.New(
border.WithInnerPaddingTop(),
border.WithTitleFn(func() string {
return border.KeyValueTitle("Total Clusters", fmt.Sprintf(" %d/%d", len(m.rows), len(m.configs)), true)
}))
return m, func() tea.Msg { return topicConfigLister.ListConfigs(topic) }
}
package consume_form_page
import (
"fmt"
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"
"ktea/ui/tabs"
"strconv"
"time"
)
type selectionState int
const (
notSelected selectionState = iota
selected
)
type Model struct {
form *huh.Form
formValues *formValues
windowResized bool
keyFilterSelectionState selectionState
valueFilterSelectionState selectionState
startPointRelativeDate selectionState
startPointAbsoluteDate selectionState
ktx *kontext.ProgramKtx
availableHeight int
topic *kadmin.ListedTopic
navigator tabs.TopicsTabNavigator
}
type startPoint int
const (
beginning startPoint = iota
mostRecent
relativeDate
absoluteDate
)
type formValues struct {
startFrom startPoint
relativeStartPoint kadmin.StartPoint
absoluteStartPoint string
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.startFrom == relativeDate && m.startPointRelativeDate == notSelected {
// if start point relative is selected and previously not selected
m.startPointRelativeDate = selected
m.form = m.newForm(m.topic.PartitionCount, m.ktx)
} else if m.formValues.startFrom != relativeDate && m.startPointRelativeDate == selected {
// if no start point relative is selected and previously selected
m.startPointRelativeDate = notSelected
m.form = m.newForm(m.topic.PartitionCount, m.ktx)
}
if m.formValues.startFrom == absoluteDate && m.startPointAbsoluteDate == notSelected {
// if start point absolute is selected and previously not selected
m.startPointAbsoluteDate = selected
m.form = m.newForm(m.topic.PartitionCount, m.ktx)
} else if m.formValues.startFrom != absoluteDate && m.startPointAbsoluteDate == selected {
// if no start point absolute is selected and previously selected
m.startPointAbsoluteDate = notSelected
m.form = m.newForm(m.topic.PartitionCount, m.ktx)
}
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 m.navigator.ToConsumePage(tabs.ConsumePageDetails{
Origin: tabs.OriginConsumeFormPage,
Topic: m.topic,
ReadDetails: kadmin.ReadDetails{
TopicName: m.topic.Name,
PartitionToRead: partToConsume,
StartPoint: m.toStartPoint(),
Limit: m.formValues.limit,
Filter: &filter,
},
})
}
func (m *Model) toStartPoint() kadmin.StartPoint {
switch m.formValues.startFrom {
case beginning:
return kadmin.Beginning
case mostRecent:
return kadmin.MostRecent
case relativeDate:
return m.formValues.relativeStartPoint
case absoluteDate:
t, _ := time.Parse(time.RFC3339, m.formValues.absoluteStartPoint)
return kadmin.StartPoint(t.UnixMilli())
}
panic(fmt.Sprintf("unknown start point %v", m.formValues.startFrom))
}
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 fmt.Sprintf("Consume from %s", m.topic.Name)
}
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 := 16 // 16 fixed height of form minus partitions field + padding and margins
if m.startPointRelativeDate == selected {
optionsHeight += 5
}
if m.startPointAbsoluteDate == selected {
optionsHeight += 4
}
if len(partOptions) < 13 {
optionsHeight = len(partOptions) + 2 // 2 for field title + padding
} else {
optionsHeight = m.availableHeight - optionsHeight
}
topicGroup := huh.NewGroup(
m.createTopicGroup(optionsHeight, ktx, partOptions)...)
filterGroup := m.createFilterGroup()
form := huh.NewForm(
topicGroup.WithWidth(ktx.WindowWidth/2),
filterGroup,
)
form.WithLayout(huh.LayoutColumns(2))
form.Init()
return form
}
func (m *Model) createTopicGroup(
optionsHeight int,
ktx *kontext.ProgramKtx,
partOptions []huh.Option[int],
) []huh.Field {
var fields []huh.Field
fields = append(fields,
huh.NewSelect[startPoint]().
Value(&m.formValues.startFrom).
Title("Start from").
Options(
huh.NewOption("Beginning", beginning),
huh.NewOption("Most Recent", mostRecent),
huh.NewOption("Relative Date", relativeDate),
huh.NewOption("Absolute Date", absoluteDate)),
)
if m.formValues.startFrom == relativeDate {
fields = append(
fields,
huh.NewSelect[kadmin.StartPoint]().
Value(&m.formValues.relativeStartPoint).
Title("Relatively Start from").
Options(
huh.NewOption("Today", kadmin.Today),
huh.NewOption("Yesterday", kadmin.Yesterday),
huh.NewOption("Week ago", kadmin.Last7Days)))
}
if m.formValues.startFrom == absoluteDate {
fields = append(
fields,
huh.NewInput().
Value(&m.formValues.absoluteStartPoint).
Description("format(RFC3339): 1986-01-16T23:20:50.52Z").
Validate(func(v string) error {
if _, e := time.Parse(time.RFC3339, v); e != nil {
return fmt.Errorf("invalid date time format")
}
return nil
}).
Title("Absolutely Start from"))
}
fields = append(
fields,
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)))
return fields
}
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,
navigator tabs.TopicsTabNavigator,
ktx *kontext.ProgramKtx,
) *Model {
var partitionsToRead []int
if topic.PartitionCount != len(details.PartitionToRead) {
partitionsToRead = details.PartitionToRead
}
return &Model{
ktx: ktx,
navigator: navigator,
topic: topic,
formValues: &formValues{
startFrom: toFormStartPoint(details.StartPoint),
absoluteStartPoint: toAbsoluteStartPoint(details.StartPoint),
relativeStartPoint: details.StartPoint,
limit: details.Limit,
partitions: partitionsToRead,
keyFilter: details.Filter.KeyFilter,
keyFilterTerm: details.Filter.KeySearchTerm,
valueFilter: details.Filter.ValueFilter,
valueFilterTerm: details.Filter.ValueSearchTerm,
}}
}
func toAbsoluteStartPoint(sp kadmin.StartPoint) string {
switch sp {
case kadmin.Beginning, kadmin.MostRecent, kadmin.Today, kadmin.Yesterday, kadmin.Last7Days:
return ""
case kadmin.Live:
panic("live not supported in form")
default:
t := time.UnixMilli(int64(sp))
return t.Format(time.RFC3339)
}
}
func toFormStartPoint(sp kadmin.StartPoint) startPoint {
switch sp {
case kadmin.Beginning:
return beginning
case kadmin.MostRecent:
return mostRecent
case kadmin.Today, kadmin.Yesterday, kadmin.Last7Days:
return relativeDate
case kadmin.Live:
panic("live not supported in form")
default:
return absoluteDate
}
}
func New(
topic *kadmin.ListedTopic,
navigator tabs.TopicsTabNavigator,
ktx *kontext.ProgramKtx,
) *Model {
return &Model{
topic: topic,
navigator: navigator,
formValues: &formValues{},
ktx: ktx,
}
}
package consume_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
sortByCBar *cmdbar.SortByCmdBar
searchCBar *cmdbar.SearchCmdBar
}
func (c *ConsumptionCmdBar) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
if c.active != nil {
return 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 tea.KeyMsg:
switch msg.String() {
case "/":
active, _, cmd := c.searchCBar.Update(msg)
if !active {
c.active = nil
} else {
c.active = c.searchCBar
c.sortByCBar.Active = false
}
return cmd
case "f3":
active, _, cmd := c.sortByCBar.Update(msg)
if !active {
c.active = nil
} else {
c.active = c.sortByCBar
c.searchCBar.Hide()
}
return cmd
default:
if c.active != nil {
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) IsFocussed() bool {
return c.active != nil && c.active.IsFocussed()
}
func (c *ConsumptionCmdBar) Shortcuts() []statusbar.Shortcut {
if c.active == nil {
return nil
}
return c.active.Shortcuts()
}
func (c *ConsumptionCmdBar) GetSearchTerm() string {
return c.searchCBar.GetSearchTerm()
}
func (c *ConsumptionCmdBar) IsSorting() bool {
return c.active == c.sortByCBar
}
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 kadmin.ConsumptionEndedMsg, m *notifier.Model) (bool, tea.Cmd) {
m.Idle()
return false, nil
}
emptyTopicMsgHandler := func(_ kadmin.EmptyTopicMsg, m *notifier.Model) (bool, tea.Cmd) {
m.Idle()
return false, nil
}
noRecordFoundMsgHandler := func(_ kadmin.NoRecordsFound, m *notifier.Model) (bool, tea.Cmd) {
m.Idle()
return false, nil
}
notifierCmdBar := cmdbar.NewNotifierCmdBar("consumption-bar")
cmdbar.BindNotificationHandler(notifierCmdBar, readingStartedNotifier)
cmdbar.BindNotificationHandler(notifierCmdBar, consumptionEndedNotifier)
cmdbar.BindNotificationHandler(notifierCmdBar, emptyTopicMsgHandler)
cmdbar.BindNotificationHandler(notifierCmdBar, noRecordFoundMsgHandler)
cmdbar.BindNotificationHandler(notifierCmdBar, c)
sortByCmdBar := cmdbar.NewSortByCmdBar(
[]cmdbar.SortLabel{
{
Label: "Key",
Direction: cmdbar.Asc,
},
{
Label: "Timestamp",
Direction: cmdbar.Desc,
},
{
Label: "Partition",
Direction: cmdbar.Desc,
},
{
Label: "Offset",
Direction: cmdbar.Desc,
},
},
cmdbar.WithInitialSortColumn("Timestamp", cmdbar.Desc),
)
return &ConsumptionCmdBar{
notifierWidget: notifierCmdBar,
sortByCBar: sortByCmdBar,
searchCBar: cmdbar.NewSearchCmdBar("Search by key or record value"),
}
}
package consume_page
import (
"context"
"fmt"
"ktea/kadmin"
"ktea/kontext"
"ktea/styles"
"ktea/ui"
"ktea/ui/components/border"
"ktea/ui/components/cmdbar"
"ktea/ui/components/statusbar"
"ktea/ui/pages"
"ktea/ui/tabs"
"sort"
"strconv"
"strings"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type Model struct {
table *table.Model
border *border.Model
cmdBar *ConsumptionCmdBar
cancelConsumption context.CancelFunc
reader kadmin.RecordReader
rows []table.Row
records []kadmin.ConsumerRecord
readDetails kadmin.ReadDetails
consuming bool
noRecordsAvailable bool
noRecordsFound bool
topic *kadmin.ListedTopic
origin tabs.Origin
navigator tabs.TopicsTabNavigator
}
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 m.noRecordsFound {
views = append(views, styles.CenterText(ktx.WindowWidth, ktx.AvailableHeight).
Render("👀 No records found for the given criteria"))
} else {
keyCol := int(float64(ktx.WindowWidth) * 0.4)
tsCol := int(float64(ktx.WindowWidth) * 0.2)
PCol := int(float64(ktx.WindowWidth) * 0.2)
oCol := ktx.WindowWidth - keyCol - tsCol - PCol - 10
m.table.SetColumns([]table.Column{
{Title: m.cmdBar.sortByCBar.PrefixSortIcon("Key"), Width: keyCol},
{Title: m.cmdBar.sortByCBar.PrefixSortIcon("Timestamp"), Width: tsCol},
{Title: m.cmdBar.sortByCBar.PrefixSortIcon("Partition"), Width: PCol},
{Title: m.cmdBar.sortByCBar.PrefixSortIcon("Offset"), Width: oCol},
})
m.table.SetRows(m.rows)
m.table.SetWidth(ktx.WindowWidth - 2)
m.table.SetHeight(ktx.AvailableTableHeight())
views = append(views, m.border.View(m.table.View()))
}
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 msg.String() == "esc" {
m.cancelConsumption()
if m.readDetails.StartPoint == kadmin.Live || m.origin == tabs.OriginTopicsPage {
return m.navigator.ToTopicsPage()
}
return m.navigator.ToConsumeFormPage(
tabs.ConsumeFormPageDetails{
ReadDetails: &m.readDetails,
Topic: m.topic,
},
)
} else if msg.String() == "f2" {
m.cancelConsumption()
m.consuming = false
cmds = append(cmds, ui.PublishMsg(kadmin.ConsumptionEndedMsg{}))
} else if msg.String() == "enter" {
if !m.cmdBar.IsFocussed() {
if len(m.records) > 0 {
selectedRow := m.rows[m.table.Cursor()]
selectedRecord := m.recordForRow(selectedRow)
m.consuming = false
recordIndex := m.recordIndexForRow(selectedRow)
return m.navigator.ToRecordDetailsPage(
tabs.LoadRecordDetailPageMsg{
Record: &selectedRecord,
TopicName: m.readDetails.TopicName,
Records: m.records,
Index: recordIndex,
})
}
}
}
case kadmin.EmptyTopicMsg:
m.noRecordsAvailable = true
m.consuming = false
case kadmin.NoRecordsFound:
m.noRecordsFound = true
m.consuming = false
case *kadmin.ReadingStartedMsg:
m.consuming = true
cmds = append(cmds, msg.AwaitRecord)
case kadmin.ConsumptionEndedMsg:
m.consuming = false
case kadmin.ConsumerRecordReceived:
m.records = append(m.records, msg.Records...)
cmds = append(cmds, msg.AwaitNextRecord)
}
cmd := m.cmdBar.Update(msg)
cmds = append(cmds, cmd)
m.rows = m.createRows()
// 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)
}
return tea.Batch(cmds...)
}
func (m *Model) recordForRow(row table.Row) kadmin.ConsumerRecord {
offset, _ := strconv.ParseInt(row[3], 10, 64)
partition, _ := strconv.ParseInt(row[2], 10, 32)
for _, rec := range m.records {
if rec.Partition == partition &&
rec.Offset == offset {
return rec
}
}
panic(fmt.Sprintf("Record not found for row: %v", row))
}
func (m *Model) recordIndexForRow(row table.Row) int {
offset, _ := strconv.ParseInt(row[3], 10, 64)
partition, _ := strconv.ParseInt(row[2], 10, 32)
for i, rec := range m.records {
if rec.Partition == partition &&
rec.Offset == offset {
return i
}
}
panic(fmt.Sprintf("Record not found for row: %v", row))
}
func (m *Model) createRows() []table.Row {
var rows []table.Row
for _, rec := range m.records {
var key string
if rec.Key == "" {
key = "<null>"
} else {
key = rec.Key
}
if m.cmdBar.GetSearchTerm() != "" {
if strings.Contains(strings.ToLower(rec.Key), strings.ToLower(m.cmdBar.GetSearchTerm())) ||
strings.Contains(strings.ToLower(rec.Payload.Value), strings.ToLower(m.cmdBar.GetSearchTerm())) {
rows = append(
rows,
table.Row{
key,
rec.Timestamp.Format("2006-01-02 15:04:05"),
strconv.FormatInt(rec.Partition, 10),
strconv.FormatInt(rec.Offset, 10),
},
)
}
} else {
rows = append(
rows,
table.Row{
key,
rec.Timestamp.Format("2006-01-02 15:04:05"),
strconv.FormatInt(rec.Partition, 10),
strconv.FormatInt(rec.Offset, 10),
},
)
}
}
sort.SliceStable(rows, func(i, j int) bool {
var col int
switch m.cmdBar.sortByCBar.SortedBy().Label {
case "Key":
col = 0
case "Timestamp":
col = 1
case "Partition":
col = 2
case "Offset":
col = 3
default:
panic(fmt.Sprintf("unexpected sort label: %s", m.cmdBar.sortByCBar.SortedBy().Label))
}
if m.cmdBar.sortByCBar.SortedBy().Direction == cmdbar.Asc {
return rows[i][col] < rows[j][col]
}
return rows[i][col] > rows[j][col]
})
return rows
}
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 || m.noRecordsFound {
return []statusbar.Shortcut{
{"Go Back", "esc"},
}
} else if m.cmdBar.IsSorting() {
return []statusbar.Shortcut{
{"Cancel Sorting", "F3"},
{"Select Sorting Column", "←/→/h/l"},
{"Apply Sorting Column", "enter"},
}
}
return []statusbar.Shortcut{
{"View Record", "enter"},
{"Sort", "F3"},
{"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,
origin tabs.Origin,
navigator tabs.TopicsTabNavigator,
) (pages.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
m.navigator = navigator
m.origin = origin
ctx, cancelFn := context.WithCancel(context.Background())
m.cancelConsumption = cancelFn
m.border = border.New(
border.WithInnerPaddingTop(),
border.WithTitleFn(func() string {
return border.KeyValueTitle("Records", fmt.Sprintf(" %d", len(m.rows)), true)
}))
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"
"ktea/ui/tabs"
"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 (
authMethodNone authSelection = 0
authMethodSaslPlaintext authSelection = 1
authMethodSaslScram authSelection = 2
authMethodNotSelected authSelection = 3
none formState = 6
loading formState = 7
notifierCmdbarTag = "upsert-cluster-page"
cTab border.TabLabel = "f4"
srTab border.TabLabel = "f5"
kcTab border.TabLabel = "f6"
)
type Model struct {
navigator tabs.ClustersTabNavigator
form *huh.Form // the active form
formState formState
srForm *huh.Form
cForm *huh.Form
cFormValues *clusterFormValues
clusterToEdit *config.Cluster
isEditing bool
notifierCmdBar *cmdbar.NotifierCmdBar
ktx *kontext.ProgramKtx
clusterRegisterer config.ClusterRegisterer
kConnChecker kadmin.ConnChecker
srConnChecker sradmin.ConnChecker
authSelState authSelection
transportOption transportOption
verificationOption verificationOption
preEditName *string
shortcuts []statusbar.Shortcut
title string
border *border.Model
kcModel *UpsertKcModel
hasVerificationStatePreviouslyNotSelected bool
validateCert kadmin.CertValidationFunc
clusterSaved bool
}
type transportOption string
const (
transportOptionNotSelected transportOption = ""
transportOptionPlaintext transportOption = "PLAINTEXT"
transportOptionTLS transportOption = "TLS"
)
type verificationOption string
const (
verificationOptionNotSelected verificationOption = ""
verificationOptionBroker verificationOption = "BROKER"
verificationOptionSkip verificationOption = "SKIP"
)
type clusterFormValues struct {
name string
color string
host string
authMethod config.AuthMethod
transportOption transportOption
verificationOption verificationOption
brokerCACertPath string
clientCertPath string
clientKeyPath string
username string
password string
srURL string
srUsername string
srPassword string
srTransportOption transportOption
srVerificationOption verificationOption
srCACertPath string
srClientCertPath string
srClientKeyPath string
}
func (cv *clusterFormValues) toTLSConfig() config.TLSConfig {
if cv.transportOption == transportOptionTLS {
skipVerify := cv.verificationOption == verificationOptionSkip
return config.TLSConfig{
Enable: true,
SkipVerify: skipVerify,
CACertPath: cv.brokerCACertPath,
ClientCert: cv.clientCertPath,
ClientKey: cv.clientKeyPath,
}
}
return config.TLSConfig{
Enable: false,
SkipVerify: false,
CACertPath: "",
ClientCert: "",
ClientKey: "",
}
}
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 - 2).
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.navigator.ToClustersPage()
case "ctrl+r":
m.cFormValues = &clusterFormValues{}
if activeTab == cTab {
m.authSelState = authMethodNone
m.transportOption = transportOptionNotSelected
m.verificationOption = verificationOptionNotSelected
m.cForm = m.createCForm()
m.form = m.cForm
} else {
m.srForm = m.createSrForm()
m.form = m.srForm
}
case "f4":
m.form = m.cForm
m.border.GoTo("f4")
return nil
case "f5":
if m.clusterSaved {
m.form = m.srForm
m.form.State = huh.StateNormal
m.border.GoTo("f5")
return nil
}
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
}
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.formState = loading
cmds = append(cmds, msg.AwaitCompletion)
case kadmin.ConnCheckSucceededMsg:
m.formState = none
cmds = append(cmds, m.registerCluster)
case sradmin.ConnCheckStartedMsg:
m.formState = loading
cmds = append(cmds, msg.AwaitCompletion)
case sradmin.ConnCheckSucceededMsg:
m.formState = none
return m.registerCluster
case config.ClusterRegisteredMsg:
m.preEditName = &msg.Cluster.Name
m.clusterToEdit = msg.Cluster
m.formState = none
m.border.WithInActiveColor(styles.ColorGrey)
m.clusterSaved = true
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 {
t, done := m.updateClusterTab()
if done {
return t
}
}
if activeTab == srTab {
m.updateSrTransportOption()
m.updateSrVO()
if m.form.State == huh.StateCompleted && m.formState != loading {
return m.processSrSubmission()
}
}
return tea.Batch(cmds...)
}
func (m *Model) updateClusterTab() (tea.Cmd, bool) {
m.updateTransportOption()
m.updateVO()
if !m.cFormValues.selectedSASLAuthMethod() &&
(m.authSelState == authMethodSaslPlaintext || m.authSelState == authMethodSaslScram) {
// if SASL authentication mode was previously selected and switched back to none
m.cForm = m.createCForm()
m.form = m.cForm
if m.cFormValues.selectedTLSTransportOption() {
if m.cFormValues.selectedBrokerVerificationOption() {
m.nextField(8)
} else {
m.nextField(7)
}
} else {
m.nextField(4)
}
m.authSelState = authMethodNone
} else if m.cFormValues.selectedSASLAuthMethod() &&
(m.authSelState == authMethodNotSelected || m.authSelState == authMethodNone) {
// SASL authentication mode selected and previously nothing or none auth mode was selected
m.cForm = m.createCForm()
m.form = m.cForm
if m.cFormValues.authMethod == config.AuthMethodSASLPlaintext {
m.authSelState = authMethodSaslPlaintext
} else {
m.authSelState = authMethodSaslScram
}
if m.cFormValues.selectedTLSTransportOption() {
if m.cFormValues.selectedBrokerVerificationOption() {
m.nextField(8)
} else {
m.nextField(7)
}
} else {
m.nextField(4)
}
}
if m.form.State == huh.StateCompleted && m.formState != loading {
return m.processClusterSubmission(), true
}
return nil, false
}
func (m *Model) updateTransportOption() {
if m.cFormValues.selectedTLSTransportOption() && m.prevSelPlaintextOrNoTO() {
m.cForm = m.createCForm()
m.form = m.cForm
m.transportOption = m.cFormValues.transportOption
m.nextField(3)
} else if m.cFormValues.selectedPlainTextTransportOption() && m.prevSelTlsTO() {
m.transportOption = m.cFormValues.transportOption
m.verificationOption = verificationOptionNotSelected
m.cFormValues.verificationOption = verificationOptionNotSelected
m.hasVerificationStatePreviouslyNotSelected = true
m.cForm = m.createCForm()
m.form = m.cForm
m.nextField(3)
}
}
func (m *Model) updateVO() {
if m.cFormValues.selectedBrokerVerificationOption() && m.verificationOption != verificationOptionBroker {
m.cForm = m.createCForm()
m.form = m.cForm
m.verificationOption = verificationOptionBroker
m.nextField(3)
if m.hasVerificationStatePreviouslyNotSelected {
m.hasVerificationStatePreviouslyNotSelected = false
} else {
m.nextField(1)
}
} else if !m.cFormValues.selectedBrokerVerificationOption() && m.verificationOption == verificationOptionBroker {
m.cForm = m.createCForm()
m.form = m.cForm
m.verificationOption = m.cFormValues.verificationOption
m.nextField(4)
}
}
func (m *Model) updateSrTransportOption() {
if m.cFormValues.srTransportOption == transportOptionTLS && m.cFormValues.srTransportOption != m.transportOption {
m.srForm = m.createSrForm()
m.form = m.srForm
m.nextField(3)
} else if m.cFormValues.srTransportOption == transportOptionPlaintext && m.transportOption == transportOptionTLS {
m.srForm = m.createSrForm()
m.form = m.srForm
m.nextField(3)
}
m.transportOption = m.cFormValues.srTransportOption
}
func (m *Model) updateSrVO() {
if m.cFormValues.srVerificationOption == verificationOptionBroker && m.verificationOption != verificationOptionBroker {
m.srForm = m.createSrForm()
m.form = m.srForm
m.verificationOption = verificationOptionBroker
m.nextField(4)
m.nextField(1)
} else if m.cFormValues.srVerificationOption == verificationOptionSkip && m.verificationOption == verificationOptionBroker {
m.srForm = m.createSrForm()
m.form = m.srForm
m.verificationOption = verificationOptionSkip
m.nextField(5)
}
}
func (m *Model) prevSelPlaintextOrNoTO() bool {
return m.transportOption == transportOptionNotSelected || m.transportOption == transportOptionPlaintext
}
func (m *Model) prevSelNoVO() bool {
return m.verificationOption == verificationOptionNotSelected
}
func (m *Model) prevSelTlsTO() bool {
return m.transportOption == transportOptionTLS
}
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.formState = loading
details := m.getRegistrationDetails()
cluster := config.ToCluster(details)
return func() tea.Msg {
return m.srConnChecker(cluster.SchemaRegistry)
}
}
func (m *Model) processClusterSubmission() tea.Cmd {
m.formState = 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.cFormValues.name
newName = nil
} else { // When updating a cluster.
name = *m.preEditName
if m.cFormValues.name != *m.preEditName {
newName = &m.cFormValues.name
}
}
var authMethod config.AuthMethod
if m.cFormValues.selectedSASLAuthMethod() {
authMethod = m.cFormValues.authMethod
} else {
authMethod = config.AuthMethodNone
}
details := config.RegistrationDetails{
Name: name,
NewName: newName,
Color: m.cFormValues.color,
Host: m.cFormValues.host,
AuthMethod: authMethod,
TLSConfig: m.cFormValues.toTLSConfig(),
Username: m.cFormValues.username,
Password: m.cFormValues.password,
}
if m.cFormValues.schemaRegistryEnabled() {
details.SchemaRegistry = &config.SchemaRegistryDetails{
Url: m.cFormValues.srURL,
Username: m.cFormValues.srUsername,
Password: m.cFormValues.srPassword,
}
if m.cFormValues.srTransportOption == transportOptionTLS {
details.SchemaRegistry.TLSConfig = config.TLSConfig{
Enable: true,
SkipVerify: m.cFormValues.srVerificationOption == verificationOptionSkip,
CACertPath: m.cFormValues.srCACertPath,
ClientCert: m.cFormValues.srClientCertPath,
ClientKey: m.cFormValues.srClientKeyPath,
}
}
}
details.KafkaConnectClusters = m.kcModel.clusterDetails()
return details
}
func (cv *clusterFormValues) selectedSASLAuthMethod() bool {
return cv.authMethod == config.AuthMethodSASLPlaintext ||
cv.authMethod == config.AuthMethodSASLSCRAMSHA256 ||
cv.authMethod == config.AuthMethodSASLSCRAMSHA512
}
func (cv *clusterFormValues) schemaRegistryEnabled() bool {
return len(cv.srURL) > 0
}
func (cv *clusterFormValues) selectedBrokerVerificationOption() bool {
return cv.verificationOption == verificationOptionBroker
}
func (cv *clusterFormValues) selectedTLSTransportOption() bool {
return cv.transportOption == transportOptionTLS
}
func (cv *clusterFormValues) selectedPlainTextTransportOption() bool {
return cv.transportOption == transportOptionPlaintext
}
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.cFormValues.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.cFormValues.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),
).Inline(true)
host := huh.NewInput().
Value(&m.cFormValues.host).
Title("Host").
Validate(func(v string) error {
if v == "" {
return errors.New("host cannot be empty")
}
return nil
})
transport := huh.NewSelect[transportOption]().
Value(&m.cFormValues.transportOption).
Title("Transport").
Options(
huh.NewOption("Plaintext", transportOptionPlaintext),
huh.NewOption("TLS", transportOptionTLS),
)
var clusterFields []huh.Field
clusterFields = append(clusterFields, name, color, host, transport)
if m.cFormValues.selectedTLSTransportOption() {
tlsVerification := huh.NewSelect[verificationOption]().
Value(&m.cFormValues.verificationOption).
Title("Verification").
Options(
huh.NewOption("Verify Broker Certificate", verificationOptionBroker),
huh.NewOption("Skip verification (INSECURE)", verificationOptionSkip),
)
clusterFields = append(clusterFields, tlsVerification)
}
if m.cFormValues.selectedBrokerVerificationOption() {
caCert := huh.NewInput().
Value(&m.cFormValues.brokerCACertPath).
Title("Path to Broker CA Certificate").
Validate(func(certFile string) error {
if certFile == "" {
return errors.New("broker CA Certificate Path cannot be empty")
}
return m.validateCert(certFile)
})
clusterFields = append(clusterFields, caCert)
}
if m.cFormValues.selectedTLSTransportOption() {
clientCert := huh.NewInput().
Value(&m.cFormValues.clientCertPath).
Title("Path to Client Certificate (optional)")
clientKey := huh.NewInput().
Value(&m.cFormValues.clientKeyPath).
Title("Path to Client Key (optional)")
clusterFields = append(clusterFields, clientCert, clientKey)
}
auth := huh.NewSelect[config.AuthMethod]().
Value(&m.cFormValues.authMethod).
Title("Authentication method").
Options(
huh.NewOption("NONE", config.AuthMethodNone),
huh.NewOption("SASL_PLAINTEXT", config.AuthMethodSASLPlaintext),
huh.NewOption("SASL_SCRAM_SHA256", config.AuthMethodSASLSCRAMSHA256),
huh.NewOption("SASL_SCRAM_SHA512", config.AuthMethodSASLSCRAMSHA512),
)
clusterFields = append(clusterFields, auth)
if m.cFormValues.selectedSASLAuthMethod() {
username := huh.NewInput().
Value(&m.cFormValues.username).
Title("SASL username")
pwd := huh.NewInput().
Value(&m.cFormValues.password).
EchoMode(huh.EchoModePassword).
Title("SASL password")
clusterFields = append(clusterFields, 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.cFormValues.srURL).
Title("Schema Registry URL")
srUsername := huh.NewInput().
Value(&m.cFormValues.srUsername).
Title("Schema Registry Username")
srPwd := huh.NewInput().
Value(&m.cFormValues.srPassword).
EchoMode(huh.EchoModePassword).
Title("Schema Registry Password")
fields = append(fields, srUrl, srUsername, srPwd)
transport := huh.NewSelect[transportOption]().
Value(&m.cFormValues.srTransportOption).
Title("Transport").
Options(
huh.NewOption("Plaintext", transportOptionPlaintext),
huh.NewOption("TLS", transportOptionTLS),
)
fields = append(fields, transport)
if m.cFormValues.srTransportOption == transportOptionTLS {
tlsVerification := huh.NewSelect[verificationOption]().
Value(&m.cFormValues.srVerificationOption).
Title("Verification").
Options(
huh.NewOption("Verify Certificate", verificationOptionBroker),
huh.NewOption("Skip verification (INSECURE)", verificationOptionSkip),
)
fields = append(fields, tlsVerification)
if m.cFormValues.srVerificationOption == verificationOptionBroker {
caCert := huh.NewInput().
Value(&m.cFormValues.srCACertPath).
Title("Path to CA Certificate").
Validate(func(certFile string) error {
if certFile == "" {
return errors.New("CA Certificate Path cannot be empty")
}
return m.validateCert(certFile)
})
fields = append(fields, caCert)
}
clientCert := huh.NewInput().
Value(&m.cFormValues.srClientCertPath).
Title("Path to Client Certificate (optional)")
clientKey := huh.NewInput().
Value(&m.cFormValues.srClientKeyPath).
Title("Path to Client Key (optional)")
fields = append(fields, clientCert, clientKey)
}
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.BindNotificationHandler(m.notifierCmdBar, func(msg kadmin.ConnCheckStartedMsg, m *notifier.Model) (bool, tea.Cmd) {
return true, m.SpinWithLoadingMsg("Testing cluster connectivity")
})
cmdbar.BindNotificationHandler(m.notifierCmdBar, func(msg kadmin.ConnCheckSucceededMsg, m *notifier.Model) (bool, tea.Cmd) {
return true, m.SpinWithLoadingMsg("Connection success creating cluster")
})
cmdbar.BindNotificationHandler(m.notifierCmdBar, func(msg kadmin.ConnCheckErrMsg, nm *notifier.Model) (bool, tea.Cmd) {
m.cForm = m.createCForm()
m.form = m.cForm
m.formState = none
nMsg := "Failed to Create Cluster"
if m.inEditingMode() {
nMsg = "Failed to Update Cluster"
}
return true, nm.ShowErrorMsg(nMsg, msg.Err)
})
cmdbar.BindNotificationHandler(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.BindNotificationHandler(m.notifierCmdBar, func(msg sradmin.ConnCheckErrMsg, nm *notifier.Model) (bool, tea.Cmd) {
m.srForm = m.createSrForm()
m.form = m.srForm
m.formState = none
nm.ShowErrorMsg("Failed to register schema registry", msg.Err)
return true, nm.AutoHideCmd(notifierCmdbarTag)
})
}
func (m *Model) inEditingMode() bool {
return m.isEditing
}
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(
navigator tabs.ClustersTabNavigator,
kConnChecker kadmin.ConnChecker,
srConnChecker sradmin.ConnChecker,
registerer config.ClusterRegisterer,
ktx *kontext.ProgramKtx,
shortcuts []statusbar.Shortcut,
certValidator kadmin.CertValidationFunc,
options ...Option,
) *Model {
formValues := &clusterFormValues{}
model := Model{
navigator: navigator,
cFormValues: formValues,
kConnChecker: kConnChecker,
srConnChecker: srConnChecker,
shortcuts: shortcuts,
validateCert: certValidator,
isEditing: false,
}
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(
navigator,
ktx,
nil,
[]config.KafkaConnectConfig{},
kcadmin.CheckKafkaConnectClustersConn,
model.notifierCmdBar,
model.registerCluster,
)
model.clusterRegisterer = registerer
model.authSelState = authMethodNotSelected
model.transportOption = transportOptionNotSelected
model.verificationOption = verificationOptionNotSelected
model.hasVerificationStatePreviouslyNotSelected = true
model.formState = none
if model.cFormValues.selectedSASLAuthMethod() {
model.authSelState = authMethodSaslPlaintext
}
for _, option := range options {
option(&model)
}
return &model
}
func NewEditClusterPage(
navigator tabs.ClustersTabNavigator,
kConnChecker kadmin.ConnChecker,
srConnChecker sradmin.ConnChecker,
registerer config.ClusterRegisterer,
connectClusterDeleter config.ConnectClusterDeleter,
ktx *kontext.ProgramKtx,
cluster config.Cluster,
certValidator kadmin.CertValidationFunc,
options ...Option,
) *Model {
formValues := &clusterFormValues{
name: cluster.Name,
color: cluster.Color,
host: cluster.BootstrapServers[0],
}
formValues.authMethod = cluster.SASLConfig.AuthMethod
formValues.username = cluster.SASLConfig.Username
formValues.password = cluster.SASLConfig.Password
formValues.authMethod = cluster.SASLConfig.AuthMethod
if cluster.TLSConfig.Enable {
formValues.transportOption = transportOptionTLS
if cluster.TLSConfig.SkipVerify {
formValues.verificationOption = verificationOptionSkip
} else {
formValues.verificationOption = verificationOptionBroker
formValues.brokerCACertPath = cluster.TLSConfig.CACertPath
}
formValues.clientCertPath = cluster.TLSConfig.ClientCert
formValues.clientKeyPath = cluster.TLSConfig.ClientKey
} else {
formValues.transportOption = transportOptionPlaintext
}
if cluster.SchemaRegistry != nil {
formValues.srURL = cluster.SchemaRegistry.Url
formValues.srUsername = cluster.SchemaRegistry.Username
formValues.srPassword = cluster.SchemaRegistry.Password
if cluster.SchemaRegistry.TLSConfig.Enable {
formValues.srTransportOption = transportOptionTLS
if cluster.SchemaRegistry.TLSConfig.SkipVerify {
formValues.srVerificationOption = verificationOptionSkip
} else {
formValues.srVerificationOption = verificationOptionBroker
formValues.srCACertPath = cluster.SchemaRegistry.TLSConfig.CACertPath
}
formValues.srClientCertPath = cluster.SchemaRegistry.TLSConfig.ClientCert
formValues.srClientKeyPath = cluster.SchemaRegistry.TLSConfig.ClientKey
} else {
formValues.srTransportOption = transportOptionPlaintext
}
}
model := Model{
transportOption: formValues.transportOption,
verificationOption: formValues.verificationOption,
navigator: navigator,
clusterToEdit: &cluster,
isEditing: true,
cFormValues: formValues,
kConnChecker: kConnChecker,
srConnChecker: srConnChecker,
shortcuts: []statusbar.Shortcut{
{"Confirm", "enter"},
{"Next Field", "tab"},
{"Prev. Field", "s-tab"},
{"Reset Form", "C-r"},
{"Go Back", "esc"},
},
validateCert: certValidator,
clusterSaved: true,
}
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(
navigator,
ktx,
func(name string) tea.Msg {
return connectClusterDeleter.DeleteKafkaConnectCluster(cluster.Name, name)
},
cluster.KafkaConnectClusters,
kcadmin.CheckKafkaConnectClustersConn,
model.notifierCmdBar,
model.registerCluster,
)
model.clusterRegisterer = registerer
model.authSelState = authMethodNotSelected
model.formState = none
if model.cFormValues.selectedSASLAuthMethod() {
if model.cFormValues.authMethod == config.AuthMethodSASLPlaintext {
model.authSelState = authMethodSaslPlaintext
} else {
model.authSelState = authMethodSaslScram
}
}
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"
"ktea/ui/tabs"
"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]
navigator tabs.ClustersTabNavigator
}
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.navigator.ToClustersPage()
}
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(
navigator tabs.ClustersTabNavigator,
ktx *kontext.ProgramKtx,
deleter ClusterDeleter,
configs []config.KafkaConnectConfig,
connChecker kcadmin.ConnChecker,
cmdBar *cmdbar.NotifierCmdBar,
registerer tea.Cmd,
) *UpsertKcModel {
m := UpsertKcModel{}
m.navigator = navigator
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.BindNotificationHandler(m.cmdBar, func(msg kcadmin.ConnCheckStartedMsg, m *notifier.Model) (bool, tea.Cmd) {
return true, m.SpinWithLoadingMsg("Testing cluster connectivity")
})
cmdbar.BindNotificationHandler(m.cmdBar, func(msg kcadmin.ConnCheckErrMsg, nm *notifier.Model) (bool, tea.Cmd) {
m.form = m.createKcForm()
m.state = entering
nm.ShowErrorMsg("Unable transportOption reach the cluster", msg.Err)
return true, nm.AutoHideCmd(notifierCmdbarTag)
})
cmdbar.BindNotificationHandler(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.BindNotificationHandler(notifierCmdBar, func(msg sradmin.SchemaCreationStartedMsg, m *notifier.Model) (bool, tea.Cmd) {
cmd := m.SpinWithLoadingMsg("Creating Schema")
return true, cmd
})
cmdbar.BindNotificationHandler(notifierCmdBar, func(msg sradmin.SchemaCreatedMsg, m *notifier.Model) (bool, tea.Cmd) {
m.ShowSuccessMsg("Schema created")
return true, nil
})
cmdbar.BindNotificationHandler(notifierCmdBar, func(msg notifier.HideNotificationMsg, m *notifier.Model) (bool, tea.Cmd) {
m.Idle()
return true, nil
})
cmdbar.BindNotificationHandler(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"
"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"
bsp "github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
)
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("delete", "delete"),
huh.NewOption("compact", "compact"),
huh.NewOption("delete,compact", "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.BindNotificationHandler(notifierCmdBar, func(msg kadmin.TopicCreationStartedMsg, m *notifier.Model) (bool, tea.Cmd) {
cmd := m.SpinWithLoadingMsg("Creating Topic")
return true, cmd
})
cmdbar.BindNotificationHandler(notifierCmdBar, func(msg kadmin.TopicCreationErrMsg, m *notifier.Model) (bool, tea.Cmd) {
m.ShowErrorMsg("Failed to create Topic", msg.Err)
return true, nil
})
cmdbar.BindNotificationHandler(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"
"ktea/ui/tabs"
"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
navigator tabs.KConTabNavigator
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.navigator.ToKConsPage()
}
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(
navigator tabs.KConTabNavigator,
kca kcadmin.Admin,
connectorName string,
) (*Model, tea.Cmd) {
m := Model{}
m.connectorName = connectorName
m.navigator = navigator
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.BindNotificationHandler(
notifierCmdBar,
func(
msg kcadmin.ConnectorListingStartedMsg,
m *notifier.Model,
) (bool, tea.Cmd) {
cmd := m.SpinWithLoadingMsg("Loading connectors")
return true, cmd
},
)
cmdbar.BindNotificationHandler(
notifierCmdBar,
func(
msg kcadmin.ConnectorsListedMsg,
m *notifier.Model,
) (bool, tea.Cmd) {
m.Idle()
return true, nil
},
)
cmdbar.BindNotificationHandler(
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.BindNotificationHandler(
notifierCmdBar,
func(
msg kcadmin.ConnectorDeletionStartedMsg,
m *notifier.Model,
) (bool, tea.Cmd) {
cmd := m.SpinWithLoadingMsg("Deleting Connector")
return true, cmd
},
)
cmdbar.BindNotificationHandler(
notifierCmdBar,
func(
msg kcadmin.ConnectorDeletedMsg,
m *notifier.Model,
) (bool, tea.Cmd) {
cmd := m.ShowSuccessMsg("Connector deleted")
return true, cmd
},
)
cmdbar.BindNotificationHandler(
notifierCmdBar,
func(
msg kcadmin.ConnectorDeletionErrMsg,
m *notifier.Model,
) (bool, tea.Cmd) {
cmd := m.ShowError(msg.Err)
return true, cmd
},
)
cmdbar.BindNotificationHandler(
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.BindNotificationHandler(
notifierCmdBar,
func(
msg kcadmin.PauseRequestedMsg,
m *notifier.Model,
) (bool, tea.Cmd) {
m.Idle()
return true, nil
},
)
cmdbar.BindNotificationHandler(
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"
"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"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
)
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
records []kadmin.ConsumerRecord
recordIndex int
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
}
type NavigateToNextRecordMsg struct {
}
type NavigateToPrevRecordMsg struct {
}
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 "ctrl+n":
cmds = m.handleNavigateToNext(cmds)
case "ctrl+p":
cmds = m.handleNavigateToPrev(cmds)
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)
}
case NavigateToNextRecordMsg:
cmds = m.loadRecordAtIndex(m.recordIndex+1, cmds)
case NavigateToPrevRecordMsg:
cmds = m.loadRecordAtIndex(m.recordIndex-1, 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) handleNavigateToNext(cmds []tea.Cmd) []tea.Cmd {
if m.recordIndex >= len(m.records)-1 {
m.notifierCmdbar.Notifier.ShowError(fmt.Errorf("no more records"))
return cmds
}
cmds = append(cmds, ui.PublishMsg(NavigateToNextRecordMsg{}))
return cmds
}
func (m *Model) handleNavigateToPrev(cmds []tea.Cmd) []tea.Cmd {
if m.recordIndex <= 0 {
m.notifierCmdbar.Notifier.ShowError(fmt.Errorf("no previous records"))
return cmds
}
cmds = append(cmds, ui.PublishMsg(NavigateToPrevRecordMsg{}))
return cmds
}
func (m *Model) loadRecordAtIndex(index int, cmds []tea.Cmd) []tea.Cmd {
if index < 0 || index >= len(m.records) {
return cmds
}
m.record = &m.records[index]
m.recordIndex = index
m.resetViews()
m.rebuildHeaderRows()
m.updateMetaInfo()
return cmds
}
func (m *Model) rebuildHeaderRows() {
sort.SliceStable(m.record.Headers, func(i, j int) bool {
return m.record.Headers[i].Key < m.record.Headers[j].Key
})
m.headerRows = nil
for _, header := range m.record.Headers {
m.headerRows = append(m.headerRows, table.Row{header.Key})
}
}
func (m *Model) resetViews() {
m.recordVp = nil
m.schemaVp = nil
}
func (m *Model) updateMetaInfo() {
key := m.record.Key
if key == "" {
key = "<null>"
}
m.metaInfo = fmt.Sprintf("key: %s\ntimestamp: %s", key, m.record.Timestamp.Format(time.UnixDate))
if m.record.Err != nil {
m.err = m.record.Err
m.notifierCmdbar.Notifier.ShowError(m.record.Err)
} else {
m.err = nil
m.payload = ui.PrettyPrintJson(m.record.Payload.Value)
}
}
func (m *Model) updatedFocussedArea(msg tea.Msg, cmds []tea.Cmd) []tea.Cmd {
// only update the 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 len(m.records) > 1 {
shortcuts = append(shortcuts, []statusbar.Shortcut{
{"Next Record", "ctrl+n"},
{"Prev Record", "ctrl+p"},
}...)
}
if m.config.ActiveCluster().HasSchemaRegistry() && m.focus == mainViewFocus {
shortcuts = append(shortcuts, statusbar.Shortcut{
Name: "Toggle Record/Schema",
Keybinding: "<tab>",
})
}
return shortcuts
}
return []statusbar.Shortcut{
{"Go Back", "esc"},
}
}
func (m *Model) Title() string {
return "Topics / " + m.topicName + " / Partition / " + strconv.FormatInt(m.record.Partition, 10) +
" / Offset / " + strconv.FormatInt(m.record.Offset, 10)
}
func New(
record *kadmin.ConsumerRecord,
topicName string,
records []kadmin.ConsumerRecord,
recordIndex int,
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.BindNotificationHandler(notifierCmdBar, func(msg PayloadCopiedMsg, m *notifier.Model) (bool, tea.Cmd) {
m.ShowSuccessMsg("Payload copied")
return true, m.AutoHideCmd("record-details-page")
})
cmdbar.BindNotificationHandler(notifierCmdBar, func(msg SchemaCopiedMsg, m *notifier.Model) (bool, tea.Cmd) {
m.ShowSuccessMsg("Schema copied")
return true, m.AutoHideCmd("record-details-page")
})
cmdbar.BindNotificationHandler(notifierCmdBar, func(msg HeaderValueCopiedMsg, m *notifier.Model) (bool, tea.Cmd) {
m.ShowSuccessMsg("Header Value copied")
return true, m.AutoHideCmd("record-details-page")
})
cmdbar.BindNotificationHandler(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"},
}
}
borderTitle := record.PayloadType()
b := border.New(
border.WithTabs(tabs...),
border.WithTitle("[ "+borderTitle+" ]"))
return &Model{
record: record,
records: records,
recordIndex: recordIndex,
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.BindNotificationHandler(notifierCmdBar, schemaListingStartedNotifier)
cmdbar.BindNotificationHandler(notifierCmdBar, schemaDeletionStartedNotifier)
cmdbar.BindNotificationHandler(notifierCmdBar, schemaDeletedNotifier)
cmdbar.BindNotificationHandler(notifierCmdBar, schemaListedNotifier)
cmdbar.BindNotificationHandler(notifierCmdBar, func(msg SchemaCopiedMsg, m *notifier.Model) (bool, tea.Cmd) {
m.ShowSuccessMsg("Schema copied")
return true, m.AutoHideCmd("schema-details-cmd-bar")
})
cmdbar.BindNotificationHandler(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.BindNotificationHandler(notifierCmdBar, subjectListingStartedNotifier)
cmdbar.BindNotificationHandler(notifierCmdBar, subjectsListedNotifier)
cmdbar.BindNotificationHandler(notifierCmdBar, subjectDeletionStartedNotifier)
cmdbar.BindNotificationHandler(notifierCmdBar, subjectListingErrorMsg)
cmdbar.BindNotificationHandler(notifierCmdBar, subjectDeletedNotifier)
cmdbar.BindNotificationHandler(notifierCmdBar, subjectDeletionErrorNotifier)
cmdbar.BindNotificationHandler(
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"
"ktea/kadmin"
"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"
"ktea/ui/pages/nav"
"ktea/ui/tabs"
"reflect"
"slices"
"sort"
"strconv"
"strings"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/log"
)
const name = "topics-page"
type state int
const (
stateRefreshing state = iota
stateLoading
stateLoaded
)
type Model struct {
topics []kadmin.ListedTopic
table table.Model
border *border.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
navigator tabs.TopicsTabNavigator
hiddenInternalTopicsCount int
showInternalTopics 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)
available := ktx.WindowWidth
nameCol := int(float64(available) * 0.6)
partCol := int(float64(available) * 0.1)
repCol := int(float64(available) * 0.1)
cleanCol := available - nameCol - partCol - repCol - 10
m.table.SetColumns([]table.Column{
{m.sortByCmdBar.PrefixSortIcon("Name"), nameCol},
{m.sortByCmdBar.PrefixSortIcon("Partitions"), partCol},
{m.sortByCmdBar.PrefixSortIcon("Replicas"), repCol},
{m.sortByCmdBar.PrefixSortIcon("Cleanup"), cleanCol},
})
m.table.SetRows(m.rows)
m.table.SetWidth(ktx.WindowWidth - 2)
m.table.SetHeight(ktx.AvailableTableHeight())
if m.table.SelectedRow() == nil && len(m.table.Rows()) > 0 {
m.goToTop = true
}
if m.goToTop {
m.table.GotoTop()
m.goToTop = false
}
return ui.JoinVertical(lipgloss.Top, cmdBarView, m.border.View(m.table.View()))
}
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 "ctrl+n":
return ui.PublishMsg(nav.LoadCreateTopicPageMsg{})
case "ctrl+o":
topic := m.SelectedTopic()
if topic == nil {
return nil
}
return ui.PublishMsg(nav.LoadTopicConfigPageMsg{Topic: topic.Name})
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 "f4":
m.showInternalTopics = !m.showInternalTopics
m.rows = m.createRows()
return nil
case "L":
if m.SelectedTopic() == nil {
return nil
}
return ui.PublishMsg(nav.LoadLiveConsumePageMsg{Topic: m.SelectedTopic()})
case "ctrl+g":
// only accept enter when the table is focussed
if !m.tcb.IsFocussed() {
if m.SelectedTopic() != nil {
return m.navigator.ToConsumeFormPage(tabs.ConsumeFormPageDetails{
Topic: m.SelectedTopic(),
})
}
}
case "enter":
// only accept enter when the table is focussed
if !m.tcb.IsFocussed() {
if m.SelectedTopic() != nil {
return m.navigator.ToConsumePage(tabs.ConsumePageDetails{
Origin: tabs.OriginTopicsPage,
Topic: m.SelectedTopic(),
ReadDetails: kadmin.NewDefaultReadDetails(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 },
)
}
var cmd tea.Cmd
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 {
m.hiddenInternalTopicsCount = 0
var rows []table.Row
for _, topic := range m.topics {
if !m.showInternalTopics && strings.HasPrefix(topic.Name, "_") {
m.hiddenInternalTopicsCount += 1
continue
}
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),
topic.Cleanup,
},
)
}
} else {
rows = append(
rows,
table.Row{
topic.Name,
strconv.Itoa(topic.PartitionCount),
strconv.Itoa(topic.Replicas),
topic.Cleanup,
},
)
}
}
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 "Cleanup":
if m.sortByCmdBar.SortedBy().Direction == cmdbar.Asc {
return rows[i][3] < rows[j][3]
}
return rows[i][3] > rows[j][3]
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 selectedTopic != nil && t.Name == *selectedTopic {
return &t
}
}
return nil
}
func (m *Model) SelectedTopicName() *string {
selectedRow := m.table.SelectedRow()
if selectedRow != nil {
return &selectedRow[0]
}
return nil
}
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(
ka kadmin.Kadmin,
navigator tabs.TopicsTabNavigator,
) (*Model, tea.Cmd) {
var m = Model{}
m.navigator = navigator
m.shortcuts = []statusbar.Shortcut{
{"Quick Consume", "enter"},
{"Granular Consume", "C-g"},
{"Live Consume", "S-l"},
{"Search", "/"},
{"Produce", "C-p"},
{"Create", "C-n"},
{"Configs", "C-o"},
{"Delete", "F2"},
{"Sort", "F3"},
{"Toggle Internal Topics", "F4"},
{"Refresh", "F5"},
}
m.table = ktable.NewDefaultTable()
deleteMsgFn := func(topic string) string {
message := topic + lipgloss.NewStyle().
Foreground(lipgloss.Color(styles.ColorIndigo)).
Bold(true).
Render(" will be deleted permanently")
return message
}
deleteFn := func(topic string) tea.Cmd {
return func() tea.Msg {
return ka.DeleteTopic(topic)
}
}
notifierCmdBar := cmdbar.NewNotifierCmdBar(name)
cmdbar.BindNotificationHandler(
notifierCmdBar,
func(
msg kadmin.TopicListingStartedMsg,
m *notifier.Model,
) (bool, tea.Cmd) {
cmd := m.SpinWithLoadingMsg("Loading Topics")
return true, cmd
},
)
cmdbar.BindNotificationHandler(
notifierCmdBar,
func(
msg ui.RegainedFocusMsg,
model *notifier.Model,
) (bool, tea.Cmd) {
if m.state == stateRefreshing || m.state == stateLoading {
cmd := model.SpinWithLoadingMsg("Loading Topics")
return true, cmd
}
return false, nil
},
)
cmdbar.BindNotificationHandler(
notifierCmdBar,
func(
msg kadmin.TopicsListedMsg,
m *notifier.Model,
) (bool, tea.Cmd) {
m.Idle()
return false, nil
},
)
cmdbar.BindNotificationHandler(
notifierCmdBar,
func(
msg kadmin.TopicListedErrorMsg,
m *notifier.Model,
) (bool, tea.Cmd) {
m.ShowErrorMsg("Error listing Topics", msg.Err)
return true, nil
},
)
cmdbar.BindNotificationHandler(
notifierCmdBar,
func(
msg kadmin.TopicDeletedMsg,
m *notifier.Model,
) (bool, tea.Cmd) {
m.ShowSuccessMsg("Topic Deleted")
return true, m.AutoHideCmd(name)
},
)
cmdbar.BindNotificationHandler(
notifierCmdBar,
func(
msg kadmin.TopicDeletionStartedMsg,
m *notifier.Model,
) (bool, tea.Cmd) {
cmd := m.SpinWithLoadingMsg("Deleting Topic")
return true, cmd
},
)
cmdbar.BindNotificationHandler(
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,
},
{
Label: "Cleanup",
Direction: cmdbar.Desc,
},
},
)
m.sortByCmdBar = sortByCmdBar
bar := cmdbar.NewSearchCmdBar("Search topics by name")
m.tcb = cmdbar.NewTableCmdsBar[string](
cmdbar.NewDeleteCmdBar(deleteMsgFn, deleteFn),
bar,
notifierCmdBar,
sortByCmdBar,
)
m.lister = ka
m.state = stateLoading
m.border = border.New(
border.WithInnerPaddingTop(),
border.WithTitleFn(func() string {
return border.KeyValueTitle("Total Topics", fmt.Sprintf(" %d/%d", len(m.rows), len(m.topics)-m.hiddenInternalTopicsCount), m.tableFocussed)
}))
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 {
if view == "" {
return view
}
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"
"ktea/ui/pages/cgroups_page"
"ktea/ui/pages/cgroups_topics_page"
"ktea/ui/pages/nav"
)
type Model struct {
active pages.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)
// in case the active page might have changed, update the statusbar provider
m.statusbar.SetProvider(m.active)
cmds = append(cmds, cmd)
return tea.Batch(cmds...)
}
func New(
cgroupLister kadmin.CGroupLister,
cgroupDeleter kadmin.CGroupDeleter,
consumerGroupOffsetLister kadmin.OffsetLister,
statusbar *statusbar.Model,
) (*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
return m, cmd
}
package clusters_tab
import (
"fmt"
"ktea/config"
"ktea/kadmin"
"ktea/kontext"
"ktea/sradmin"
"ktea/ui"
"ktea/ui/components/statusbar"
"ktea/ui/pages"
"ktea/ui/pages/clusters_page"
"ktea/ui/pages/create_cluster_page"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type state int
type Model struct {
state state
active pages.Page
createPage pages.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,
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"},
},
kadmin.CertValidator,
)
}
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,
m.kConnChecker,
m.srConnChecker,
m.ktx.Config(),
m.ktx.Config(),
m.ktx,
*selectedCluster,
kadmin.CertValidator,
create_cluster_page.WithTitle(fmt.Sprintf("Clusters / %s / Edit", selectedCluster.Name)),
)
}
}
}
// in case the active page might have changed, update the statusbar provider
m.statusbar.SetProvider(m.active)
return m.active.Update(msg)
}
func (m *Model) ToClustersPage() tea.Cmd {
var cmd tea.Cmd
m.active, cmd = clusters_page.New(m.ktx, m.kConnChecker)
m.statusbar.SetProvider(m.active)
return cmd
}
func New(
ktx *kontext.ProgramKtx,
kConnChecker kadmin.ConnChecker,
srConnChecker sradmin.ConnChecker,
stsBar *statusbar.Model,
) (*Model, tea.Cmd) {
var cmd tea.Cmd
m := Model{}
m.kConnChecker = kConnChecker
m.srConnChecker = srConnChecker
m.ktx = ktx
m.config = ktx.Config()
m.statusbar = stsBar
if m.config.HasClusters() {
var listPage, c = clusters_page.New(ktx, m.kConnChecker)
cmd = c
m.escGoesBack = true
m.active = listPage
} else {
m.active = create_cluster_page.NewCreateClusterPage(
&m,
m.kConnChecker,
m.srConnChecker,
m.ktx.Config(),
m.ktx,
[]statusbar.Shortcut{
{"Confirm", "enter"},
{"Next Field", "tab"},
{"Prev. Field", "s-tab"},
{"Reset Form", "C-r"},
},
kadmin.CertValidator,
create_cluster_page.WithTitle("Clusters / Register"),
)
m.escGoesBack = false
}
m.statusbar.SetProvider(m.active)
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"
"ktea/ui/pages/kcon_clusters_page"
"ktea/ui/pages/kcon_page"
"net/http"
)
type Model struct {
active pages.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 {
// in case the active page might have changed, update the statusbar provider
m.statusbar.SetProvider(m.active)
return m.active.Update(msg)
}
func (m *Model) ToKConsPage() tea.Cmd {
m.active = m.kconsPage
m.statusbar.SetProvider(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, kca, c.Name)
return cmd
}
func New(cluster *config.Cluster, stsBar *statusbar.Model) (*Model, tea.Cmd) {
m := Model{}
kconsPage, cmd := kcon_clusters_page.New(cluster, m.loadKConPage)
m.kconsPage = kconsPage
m.active = kconsPage
m.statusbar = stsBar
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"
"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 pages.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)
}
// in case the active page might have changed, update the statusbar provider
m.statusbar.SetProvider(m.active)
cmds = append(cmds, m.active.Update(msg))
return tea.Batch(cmds...)
}
func New(srClient sradmin.Client, ktx *kontext.ProgramKtx, stsBar *statusbar.Model) (*Model, tea.Cmd) {
subjectsPage, cmd := subjects_page.New(srClient)
model := Model{active: subjectsPage}
model.subjectsPage = subjectsPage
model.statusbar = stsBar
model.srClient = srClient
model.ktx = ktx
return &model, cmd
}
package topics_tab
import (
"context"
"ktea/kadmin"
"ktea/kontext"
"ktea/ui"
"ktea/ui/clipper"
"ktea/ui/components/statusbar"
"ktea/ui/pages"
"ktea/ui/pages/configs_page"
"ktea/ui/pages/consume_form_page"
"ktea/ui/pages/consume_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"
"ktea/ui/tabs"
"reflect"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/log"
)
type Model struct {
active pages.Page
topicsPage *topics_page.Model
statusbar *statusbar.Model
ka kadmin.Kadmin
ktx *kontext.ProgramKtx
consumptionPage pages.Page
recordDetailsPage pages.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.LoadTopicConfigPageMsg:
page, cmd := configs_page.New(m.ka, m.ka, msg.Topic)
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.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 = consume_page.New(
m.ka,
readDetails,
msg.Topic,
tabs.OriginTopicsPage,
m,
)
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.SetProvider(m.active)
return tea.Batch(cmds...)
}
func (m *Model) ToTopicsPage() tea.Cmd {
m.active = m.topicsPage
return nil
}
func (m *Model) ToConsumePage(msg tabs.ConsumePageDetails) tea.Cmd {
var cmd tea.Cmd
m.active, cmd = consume_page.New(
m.ka,
msg.ReadDetails,
msg.Topic,
msg.Origin,
m,
)
m.consumptionPage = m.active
return cmd
}
func (m *Model) ToConsumeFormPage(d tabs.ConsumeFormPageDetails) tea.Cmd {
if d.ReadDetails != nil {
m.active = consume_form_page.NewWithDetails(
d.ReadDetails,
d.Topic,
m,
m.ktx,
)
} else {
m.active = consume_form_page.New(
d.Topic,
m,
m.ktx,
)
}
return nil
}
func (m *Model) ToRecordDetailsPage(msg tabs.LoadRecordDetailPageMsg) tea.Cmd {
m.active = record_details_page.New(msg.Record, msg.TopicName, msg.Records, msg.Index, clipper.New(), m.ktx)
m.recordDetailsPage = m.active
return nil
}
func New(ktx *kontext.ProgramKtx, ka kadmin.Kadmin, stsBar *statusbar.Model) (*Model, tea.Cmd) {
var cmd tea.Cmd
model := &Model{}
model.ka = ka
model.ktx = ktx
model.statusbar = stsBar
model.statusbar.SetProvider(model.active)
listTopicView, cmd := topics_page.New(
ka,
model,
)
model.active = listTopicView
model.topicsPage = listTopicView
return model, cmd
}