package main
import (
"flag"
"fmt"
"image/color"
"ktea/config"
"ktea/kadmin"
"ktea/kcadmin"
"ktea/kontext"
"ktea/sradmin"
"ktea/styles"
"ktea/ui"
"ktea/ui/components/help"
"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/settings_tab"
"ktea/ui/tabs/sr_tab"
"ktea/ui/tabs/topics_tab"
"os"
"slices"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"charm.land/log/v2"
)
var version string
const (
topicsTabLbl tab.Label = "topics"
cgroupsTabLbl = "cgroups"
schemaRegTabLbl = "schemaReg"
clustersTabLbl = "clusters"
kconnectTabLbl = "kconnect"
settingsTabLbl = "settings"
)
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}
settingsTab = tab.Tab{Title: "Settings", Label: settingsTabLbl}
)
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
settingsTabCtrl *settings_tab.Model
configIO config.IO
switchingCluster bool
startupConnErr bool
statusbar *statusbar.Model
showHelp bool
// isDark reports whether the terminal background is dark; it picks the theme variant.
isDark bool
startErr error
// the terminal's own colors, restored after a theme painted the background
termBg, termFg color.Color
painted bool
quitting bool
}
// RetryClusterConnectionMsg is an internal Msg
// to actually retry the cluster connection
type RetryClusterConnectionMsg struct {
Cluster *config.Cluster
}
func (m *Model) Init() tea.Cmd {
// Ask for the text color before loading the config: the terminal answers
// queries in order, so the reply comes before any theme color is set.
return tea.Batch(tea.Sequence(tea.RequestForegroundColor, func() tea.Msg {
return config.LoadedMsg{Config: config.New(m.configIO)}
}), tea.RequestWindowSize)
}
// terminalColors returns the terminal background and text color the view should set.
// A theme with a background also sets the text color, so unstyled text stays
// readable, e.g. Catppuccin Latte on a dark terminal.
func (m *Model) terminalColors() (bg, fg color.Color) {
if bg := styles.Current.Background; bg != nil && !m.quitting {
m.painted = true
return bg, styles.Current.Text
}
if m.painted {
// Some terminals ignore the "reset color" sequence bubbletea sends for a
// nil color, so set the original colors back explicitly.
return m.termBg, m.termFg
}
return nil, nil
}
func (m *Model) View() tea.View {
content := m.render()
if m.showHelp && m.statusbar != nil {
content = m.withHelpModal(content)
}
v := tea.NewView(content)
v.AltScreen = true
v.BackgroundColor, v.ForegroundColor = m.terminalColors()
return v
}
func (m *Model) render() 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...)
}
// withHelpModal draws the keybindings modal centered on top of content.
func (m *Model) withHelpModal(content string) string {
modal := help.View(m.statusbar.Title(), m.statusbar.Shortcuts(), m.ktx.WindowHeight)
return ui.Overlay(content, modal, m.ktx.WindowWidth, m.ktx.WindowHeight)
}
// Update repaints the whole screen when the theme changes the terminal colors.
// bubbletea only redraws cells whose content changed, and many terminals keep
// the old background on cells that are not redrawn.
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
bg := styles.Current.Background
model, cmd := m.update(msg)
if styles.Current.Background != bg {
cmd = tea.Batch(cmd, tea.ClearScreen)
}
return model, cmd
}
func (m *Model) update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.ForegroundColorMsg:
m.termFg = msg.Color
return m, nil
case tea.KeyPressMsg:
switch msg.String() {
case "ctrl+c":
m.quitting = true
return m, tea.Quit
case "f1":
m.showHelp = !m.showHelp && m.statusbar != nil
return m, nil
}
// the help modal captures all key presses while open
if m.showHelp {
if msg.String() == "esc" || msg.String() == "q" {
m.showHelp = false
}
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:
scheme, err := styles.Load(msg.Config.Theme, config.ThemesDir(), m.isDark)
if err != nil {
m.startErr = err
return m, tea.Quit
}
styles.Apply(scheme)
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, settingsTab)
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
case settingsTabLbl:
if m.settingsTabCtrl == nil {
m.settingsTabCtrl = settings_tab.New(m.ktx, config.ThemesDir(), m.isDark, m.statusbar)
}
m.tabCtrl = m.settingsTabCtrl
}
// 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, settingsTab}
if cluster.HasSchemaRegistry() {
titles = slices.Insert(titles, 2, schemaRegTab)
}
if cluster.HasKafkaConnect() {
titles = slices.Insert(titles, len(titles)-2, 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()
m.settingsTabCtrl = nil // bound to the old statusbar
if err := m.recreateAdminClients(cluster); err != nil {
m.tabs = tab.New(clustersTab, settingsTab)
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,
isDark: true,
}
}
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
}
model := NewModel(
disableNerdFonts,
kadmin.SaramaInstantiator(),
config.NewDefaultIO(),
)
// Ask the terminal before the TUI takes over stdin.
if bg, err := lipgloss.BackgroundColor(os.Stdin, os.Stdout); err == nil && bg != nil {
model.termBg = bg
model.isDark = tea.BackgroundColorMsg{Color: bg}.IsDark()
}
p := tea.NewProgram(model)
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)
}
if model.startErr != nil {
fmt.Fprintln(os.Stderr, "ktea:", model.startErr)
os.Exit(1)
}
}
package config
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
"charm.land/log/v2"
)
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"`
// Theme is a built-in theme or the name of a file in ThemesDir() (without .yaml).
Theme string `yaml:"theme,omitempty"`
}
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
}
// SetTheme saves name as the active theme.
func (c *Config) SetTheme(name string) {
c.Theme = name
c.flush()
}
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 "charm.land/bubbletea/v2"
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 "charm.land/bubbletea/v2"
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"
tea "charm.land/bubbletea/v2"
"charm.land/log/v2"
"github.com/IBM/sarama"
)
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 (
tea "charm.land/bubbletea/v2"
"github.com/IBM/sarama"
)
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 "charm.land/bubbletea/v2"
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 "charm.land/bubbletea/v2"
)
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"
tea "charm.land/bubbletea/v2"
"github.com/IBM/sarama"
)
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"
"charm.land/log/v2"
tea "charm.land/bubbletea/v2"
"github.com/IBM/sarama"
)
type FilterType string
// Matches reports whether a record satisfies every criterion set on the filter.
func (f *Filter) Matches(key, value string) bool {
if f == nil {
return true
}
if f.Term != "" {
term := strings.ToLower(f.Term)
if !strings.Contains(strings.ToLower(key), term) &&
!strings.Contains(strings.ToLower(value), term) {
return false
}
}
return f.KeyFilter.matches(key, f.KeySearchTerm) &&
f.ValueFilter.matches(value, f.ValueSearchTerm)
}
func (t FilterType) matches(value, term string) bool {
switch t {
case ContainsFilterType:
return strings.Contains(value, term)
case StartsWithFilterType:
return strings.HasPrefix(value, term)
default:
return true
}
}
const (
ContainsFilterType FilterType = "contains"
StartsWithFilterType FilterType = "starts with"
NoFilterType FilterType = "none"
)
type StartPoint int64
// idleConsumptionTimeout is how long a partition may go without delivering a message
// before checking whether all available records have been consumed.
var idleConsumptionTimeout = 2 * time.Second
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{source: m.ConsumerRecord}
}
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
}
}
// shutdown signals the end of the consumption by closing the record channel.
// The other channels are deliberately left open, as a closed channel is always
// ready in a select and would race with the closed record channel.
func (m *ReadingStartedMsg) shutdown() {
m.CancelFunc()
close(m.ConsumerRecord)
}
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{source: m.consumerRecord}
}
records := []ConsumerRecord{record}
timeout := time.After(50 * time.Millisecond)
for {
select {
case r, ok := <-m.consumerRecord:
if !ok {
// consumption ended, the next await reports it
return ConsumerRecordReceived{
Records: records,
consumerRecord: m.consumerRecord,
emptyTopic: m.emptyTopic,
noRecordsFound: m.noRecordsFound,
err: m.err,
cancelFunc: m.cancelFunc,
}
}
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 {
source chan ConsumerRecord
}
// Source identifies the consumption a message belongs to. It is nil for
// messages not tied to a specific consumption.
func (m ConsumptionEndedMsg) Source() chan ConsumerRecord { return m.source }
func (m ConsumerRecordReceived) Source() chan ConsumerRecord { return m.consumerRecord }
func (m *ReadingStartedMsg) Source() chan ConsumerRecord { return m.ConsumerRecord }
type Filter struct {
// Term matches records whose key or value contains it, ignoring case.
Term string
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
// offset after the last delivered record, i.e. the high water mark unless the
// partition ends with records that are never delivered (e.g. transaction markers)
end int64
// probed indicates end is known to follow a delivered record
probed bool
}
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.Err <- err
cancelFunc()
return
}
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
cancelFunc()
return
}
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 {
sendErr(ctx, startedMsg.Err, err)
cancelFunc()
return
}
// Closing waits for the in-flight fetch, which the broker holds for
// Consumer.MaxWaitTime when there is no new data, so close in the background.
defer func() {
consumer.AsyncClose()
go func() {
for range consumer.Messages() {
}
}()
go func() {
for range consumer.Errors() {
}
}()
}()
msgChan := consumer.Messages()
live := rd.StartPoint == Live
// When the last delivered record could not be probed, the end offset might
// never be delivered (e.g. transaction control markers), so when no message
// arrives for a while, we verify if the end has been reached.
idle := time.NewTimer(idleConsumptionTimeout)
defer idle.Stop()
if offsets[partition].probed {
idle.Stop()
}
for {
select {
case err := <-consumer.Errors():
sendErr(ctx, startedMsg.Err, err)
return
case <-ctx.Done():
return
case <-idle.C:
hwm := consumer.HighWaterMarkOffset()
if !live && hwm > 0 && hwm-1 <= readingOffsets.end {
log.Debug("No more records available",
"topic", rd.TopicName,
"partition", partition,
"end", readingOffsets.end,
"highWaterMark", hwm,
)
return
}
idle.Reset(idleConsumptionTimeout)
case msg := <-msgChan:
if !offsets[partition].probed {
if !idle.Stop() {
select {
case <-idle.C:
default:
}
}
idle.Reset(idleConsumptionTimeout)
}
reachedEnd := !live && msg.Offset >= readingOffsets.end
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 !rd.Filter.Matches(key, desData.Value) {
if reachedEnd {
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 reachedEnd {
return
}
}
}
})
}
}
if emptyTopic {
cancelFunc()
startedMsg.EmptyTopic <- true
return
}
go func() {
wg.Wait()
time.Sleep(50 * time.Millisecond)
startedMsg.shutdown()
}()
}
// sendErr reports err without blocking when the error channel is full and consumption got cancelled.
func sendErr(ctx context.Context, errChan chan error, err error) {
select {
case errChan <- err:
case <-ctx.Done():
}
}
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) 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)
off := offsets{start: startOffset, end: endOffset}
if startPoint != Live && startOffset >= 0 && endOffset > startOffset {
last, err := ka.lastDataOffset(topicName, int32(partition), startOffset, endOffset)
if err != nil {
log.Debug("Unable to probe last delivered offset",
"topic", topicName,
"partition", partition,
"err", err)
} else {
off.end = max(last+1, startOffset)
off.probed = true
log.Debug("Probed last delivered offset",
"topic", topicName,
"partition", partition,
"lastDataOffset", last)
}
}
mu.Lock()
offsetsByPartition[partition] = off
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"
tea "charm.land/bubbletea/v2"
"charm.land/log/v2"
"github.com/IBM/sarama"
"github.com/burdiyan/kafkautil"
"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 (
"errors"
"github.com/IBM/sarama"
)
const (
probeInitialWindow int64 = 64
probeMaxBytes int32 = 1 << 20
)
var errProbeUnsupported = errors.New("tail probe not supported for legacy message format")
// lastDataOffset returns the highest offset in [start, end) that holds a record a consumer
// actually receives, or -1 when there is none. The newest offset of a partition is not
// necessarily such a record, e.g. transaction control markers are never delivered.
func (ka *SaramaKafkaAdmin) lastDataOffset(
topic string,
partition int32,
start int64,
end int64,
) (int64, error) {
hi := end
window := probeInitialWindow
for hi > start {
from := max(start, hi-window)
last, err := ka.scanLastDataOffset(topic, partition, from, hi)
if err != nil {
return -1, err
}
if last >= 0 {
return last, nil
}
hi = from
window *= 4
}
return -1, nil
}
// scanLastDataOffset fetches [from, hi) and returns the highest offset of a delivered record in it, or -1.
func (ka *SaramaKafkaAdmin) scanLastDataOffset(
topic string,
partition int32,
from int64,
hi int64,
) (int64, error) {
broker, err := ka.client.Leader(topic, partition)
if err != nil {
return -1, err
}
conf := ka.client.Config()
last := int64(-1)
offset := from
for offset < hi {
request, err := newProbeFetchRequest(conf)
if err != nil {
return -1, err
}
request.AddBlock(topic, partition, offset, probeMaxBytes, -1)
response, err := broker.Fetch(request)
if err != nil {
return -1, err
}
block := response.GetBlock(topic, partition)
if block == nil {
return -1, sarama.ErrIncompleteResponse
}
if !errors.Is(block.Err, sarama.ErrNoError) {
return -1, block.Err
}
next := offset
aborted := make(map[int64]struct{})
abortedTxns := block.AbortedTransactions
for _, records := range block.RecordsSet {
batch := records.RecordBatch
if batch == nil {
return -1, errProbeUnsupported
}
if batch.PartialTrailingRecord {
break
}
next = max(next, batch.LastOffset()+1)
for len(abortedTxns) > 0 && abortedTxns[0].FirstOffset <= batch.LastOffset() {
aborted[abortedTxns[0].ProducerID] = struct{}{}
abortedTxns = abortedTxns[1:]
}
if batch.Control {
delete(aborted, batch.ProducerID)
continue
}
if conf.Consumer.IsolationLevel == sarama.ReadCommitted && batch.IsTransactional {
if _, isAborted := aborted[batch.ProducerID]; isAborted {
continue
}
}
for _, rec := range batch.Records {
o := batch.FirstOffset + rec.OffsetDelta
if o >= from && o < hi && o > last {
last = o
}
}
}
if next == offset {
// nothing (more) returned
break
}
offset = next
}
return last, nil
}
func newProbeFetchRequest(conf *sarama.Config) (*sarama.FetchRequest, error) {
if !conf.Version.IsAtLeast(sarama.V0_11_0_0) {
return nil, errProbeUnsupported
}
request := &sarama.FetchRequest{
Version: 5,
MinBytes: 1,
MaxWaitTime: 0,
MaxBytes: probeMaxBytes,
Isolation: conf.Consumer.IsolationLevel,
}
if conf.Version.IsAtLeast(sarama.V1_1_0_0) {
request.Version = 7
request.SessionID = 0
request.SessionEpoch = -1
}
return request, nil
}
package kadmin
import (
tea "charm.land/bubbletea/v2"
"github.com/IBM/sarama"
)
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 "charm.land/bubbletea/v2"
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 "charm.land/bubbletea/v2"
"charm.land/log/v2"
)
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 (
tea "charm.land/bubbletea/v2"
"github.com/IBM/sarama"
"github.com/burdiyan/kafkautil"
"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 "charm.land/bubbletea/v2"
"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 "charm.land/bubbletea/v2"
"charm.land/log/v2"
"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 "charm.land/bubbletea/v2"
"charm.land/log/v2"
"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 (
tea "charm.land/bubbletea/v2"
"charm.land/log/v2"
"fmt"
"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 (
tea "charm.land/bubbletea/v2"
"charm.land/log/v2"
"fmt"
"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 "charm.land/bubbletea/v2"
"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 (
tea "charm.land/bubbletea/v2"
"charm.land/log/v2"
"encoding/json"
"fmt"
"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 "charm.land/bubbletea/v2"
"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 "charm.land/bubbletea/v2"
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 "charm.land/bubbletea/v2"
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 "charm.land/bubbletea/v2"
"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 (
tea "charm.land/bubbletea/v2"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"fmt"
"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 "charm.land/bubbletea/v2"
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 "charm.land/bubbletea/v2"
"charm.land/log/v2"
)
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 "charm.land/bubbletea/v2"
)
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"
"image/color"
"charm.land/bubbles/v2/table"
"charm.land/huh/v2"
"charm.land/lipgloss/v2"
)
var Env EnvStyle
var Tab TabStyle
var Statusbar StatusBarStyle
var Help HelpStyle
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,
}
// Current is the active color scheme. Set it through Apply.
var Current Scheme
// Huh is the form theme. huh reads it on every render, so forms follow Apply.
var Huh huh.Theme = huh.ThemeFunc(func(bool) *huh.Styles {
t := *huhStyles // copy, so a form can't change the shared styles
return &t
})
var huhStyles *huh.Styles
// Cluster environment colors. They are stored per cluster in config.yaml,
// so they are fixed and not part of a theme.
const ColorRed = "#FF0000"
const ColorGreen = "#00FF00"
const ColorBlue = "#0000FF"
const ColorOrange = "#FFA500"
const ColorPurple = "#800080"
const ColorYellow = "#FFFF00"
const ColorWhite = "#FFFFFF"
const ColorBlack = "#000000"
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
Spacer lipgloss.Style
cluster lipgloss.Style
}
type HelpStyle struct {
Modal lipgloss.Style
Title lipgloss.Style
Key lipgloss.Style
Desc lipgloss.Style
Footer 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 {
colorLabel, colorCount := Current.TextMuted, Current.PrimaryMuted
if active {
colorLabel, colorCount = Current.Text, Current.Primary
}
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(Current.Primary)
}
func CmdBarWithWidth(width int) lipgloss.Style {
// lipgloss v2 widths include the border; keep width as the content+padding width.
return CmdBar.Width(width + CmdBar.GetHorizontalBorderSize())
}
func FG(c color.Color) lipgloss.Style {
return lipgloss.NewStyle().Foreground(c)
}
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() {
Apply(themeDefault(true))
}
// Apply makes s the active scheme and rebuilds all shared styles from it.
// Call it before building UI components; styles already rendered keep their colors.
func Apply(s Scheme) {
Current = s
huhStyles = huhTheme(s)
{
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(s.TextFaint).
Border(tabBorder)
tabStyle.Active = lipgloss.NewStyle().
Padding(0, 1).
Border(activeTabBorder).
Foreground(s.Primary).
Bold(true)
tabStyle.Help = lipgloss.NewStyle().
Padding(0, 1).
Border(helpBorder).
Foreground(s.Text).
Bold(true)
Tab = tabStyle
}
{
statusBarStyle := StatusBarStyle{}
statusBarStyle.style = lipgloss.NewStyle().
Background(s.Surface)
statusBarStyle.Indicator = lipgloss.NewStyle().
Inherit(statusBarStyle.style).
Foreground(s.OnPrimary).
Background(s.Primary).
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.Spacer = lipgloss.NewStyle().
Inherit(statusBarStyle.style).
PaddingLeft(1)
Statusbar = statusBarStyle
}
{
Help = HelpStyle{
Modal: lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(s.Primary).
Padding(1, 2),
Title: lipgloss.NewStyle().
Foreground(s.Primary).
Bold(true),
Key: lipgloss.NewStyle().
Foreground(s.Highlight).
Bold(true),
Desc: lipgloss.NewStyle().
Foreground(s.Text),
Footer: lipgloss.NewStyle().
Foreground(s.TextMuted).
Faint(true),
}
}
{
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(s.Success).
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)
tableStyles := table.DefaultStyles()
tableStyles.Header = tableStyles.Header.
BorderBottom(true).
BorderTop(false).
BorderLeft(false).
BorderRight(false).
BorderStyle(lipgloss.Border{
Bottom: "─",
}).
Bold(false)
tableStyles.Selected = tableStyles.Selected.
Foreground(s.OnPrimary).
Background(s.Primary).
Bold(true)
Table = TableStyle{
Focus: focus,
Blur: blur,
Styles: tableStyles,
}
}
}
// huhTheme starts from huh's Charm theme (ktea's look so far) and swaps in
// the scheme's colors.
func huhTheme(s Scheme) *huh.Styles {
{
t := huh.ThemeCharm(s.IsDark)
f := &t.Focused
f.Title = f.Title.Foreground(s.Accent)
f.NoteTitle = f.NoteTitle.Foreground(s.Accent)
f.Directory = f.Directory.Foreground(s.Accent)
f.Description = f.Description.Foreground(s.TextMuted)
f.ErrorIndicator = f.ErrorIndicator.Foreground(s.Error)
f.ErrorMessage = f.ErrorMessage.Foreground(s.Error)
f.SelectSelector = f.SelectSelector.Foreground(s.Primary)
f.NextIndicator = f.NextIndicator.Foreground(s.Primary)
f.PrevIndicator = f.PrevIndicator.Foreground(s.Primary)
f.Option = f.Option.Foreground(s.Text)
f.MultiSelectSelector = f.MultiSelectSelector.Foreground(s.Primary)
f.SelectedOption = f.SelectedOption.Foreground(s.Success)
f.SelectedPrefix = f.SelectedPrefix.Foreground(s.Success)
f.UnselectedOption = f.UnselectedOption.Foreground(s.Text)
f.UnselectedPrefix = f.UnselectedPrefix.Foreground(s.TextFaint)
f.FocusedButton = f.FocusedButton.Foreground(s.OnPrimary).Background(s.Primary)
f.Next = f.FocusedButton
f.BlurredButton = f.BlurredButton.Foreground(s.Text).Background(s.SurfaceAlt)
f.TextInput.Cursor = f.TextInput.Cursor.Foreground(s.Success)
f.TextInput.Placeholder = f.TextInput.Placeholder.Foreground(s.TextFaint)
f.TextInput.Prompt = f.TextInput.Prompt.Foreground(s.Primary)
t.Blurred = t.Focused
t.Blurred.Base = t.Focused.Base.BorderStyle(lipgloss.HiddenBorder())
t.Blurred.Card = t.Blurred.Base
t.Blurred.NextIndicator = lipgloss.NewStyle()
t.Blurred.PrevIndicator = lipgloss.NewStyle()
t.Group.Title = t.Focused.Title
t.Group.Description = t.Focused.Description
return t
}
}
package styles
import (
"errors"
"fmt"
"image/color"
"io"
"os"
"path/filepath"
"sort"
"strings"
"charm.land/lipgloss/v2"
chroma "github.com/alecthomas/chroma/v2/styles"
catppuccin "github.com/catppuccin/go"
"gopkg.in/yaml.v3"
)
// Scheme holds the semantic colors the UI is drawn with.
type Scheme struct {
Primary color.Color // active tab, table selection, highlighted counts
PrimaryMuted color.Color // inactive counts and chips
Accent color.Color // cmdbar prompts, form titles
Highlight color.Color // help keys
Text color.Color
TextMuted color.Color
TextFaint color.Color // inactive tabs
OnPrimary color.Color // text drawn on a Primary background
Surface color.Color // statusbar background
SurfaceAlt color.Color // inactive chips, inactive form borders
BorderFocus color.Color
BorderBlur color.Color
Active color.Color // active border tab
Success color.Color
Error color.Color
// Background, when set, paints the whole terminal while ktea runs and
// Text becomes the terminal's default text color. Nil keeps the terminal's colors.
Background color.Color
Syntax string // chroma style used to highlight record payloads
IsDark bool
}
// ThemeFunc returns the scheme for a dark or light terminal background,
// mirroring huh.ThemeFunc.
type ThemeFunc func(isDark bool) Scheme
const DefaultTheme = "default"
var builtins = map[string]ThemeFunc{
"default": themeDefault,
"dracula": themeDracula,
"catppuccin": adaptive(catppuccinScheme(catppuccin.Mocha), catppuccinScheme(catppuccin.Latte)),
"catppuccin-latte": fixed(catppuccinScheme(catppuccin.Latte)),
"catppuccin-frappe": fixed(catppuccinScheme(catppuccin.Frappe)),
"catppuccin-macchiato": fixed(catppuccinScheme(catppuccin.Macchiato)),
"catppuccin-mocha": fixed(catppuccinScheme(catppuccin.Mocha)),
"desert": themeDesert,
"tokyonight": adaptive(tokyoNightNight, tokyoNightDay),
"tokyonight-night": fixed(tokyoNightNight),
"tokyonight-storm": fixed(tokyoNightStorm),
"tokyonight-moon": fixed(tokyoNightMoon),
"tokyonight-day": fixed(tokyoNightDay),
"gruvbox": adaptive(gruvboxDark, gruvboxLight),
"gruvbox-dark": fixed(gruvboxDark),
"gruvbox-light": fixed(gruvboxLight),
"nord": fixed(nord),
"rose-pine": adaptive(rosePineMain, rosePineDawn),
"rose-pine-main": fixed(rosePineMain),
"rose-pine-moon": fixed(rosePineMoon),
"rose-pine-dawn": fixed(rosePineDawn),
"solarized": adaptive(solarizedDark, solarizedLight),
"solarized-dark": fixed(solarizedDark),
"solarized-light": fixed(solarizedLight),
"onedark": fixed(oneDark),
}
// Builtins returns the names of the built-in themes.
func Builtins() []string {
names := make([]string, 0, len(builtins))
for n := range builtins {
names = append(names, n)
}
sort.Strings(names)
return names
}
// Available lists the built-in themes followed by the user themes in dir.
func Available(dir string) []string {
names := Builtins()
entries, _ := os.ReadDir(dir) // a missing dir just means no user themes
for _, e := range entries {
name, ok := strings.CutSuffix(e.Name(), ".yaml")
if ok && !e.IsDir() && builtins[name] == nil {
names = append(names, name)
}
}
return names
}
// IsBuiltin reports whether name is a built-in theme.
func IsBuiltin(name string) bool { return builtins[name] != nil }
func c(s string) color.Color { return lipgloss.Color(s) }
func themeDefault(isDark bool) Scheme {
if isDark {
return Scheme{
Primary: c("205"),
PrimaryMuted: c("132"),
Accent: c("#7571F9"),
Highlight: c("#FFFF00"),
Text: c("#FFFFFF"),
TextMuted: c("#C1C1C1"),
TextFaint: c("#AAAAAA"),
OnPrimary: c("#000000"),
Surface: c("#353533"),
SurfaceAlt: c("#343433"),
BorderFocus: c("#F5F5F5"),
BorderBlur: c("235"),
Active: c("#800080"),
Success: c("#00FF00"),
Error: c("#FF0000"),
Syntax: "github-dark",
IsDark: true,
}
}
return Scheme{
Primary: c("162"),
PrimaryMuted: c("132"),
Accent: c("#5A56E0"),
Highlight: c("#AF8700"),
Text: c("#1A1A1A"),
TextMuted: c("#555555"),
TextFaint: c("#777777"),
OnPrimary: c("#FFFFFF"),
Surface: c("#E4E4E4"),
SurfaceAlt: c("#D0D0D0"),
BorderFocus: c("#303030"),
BorderBlur: c("250"),
Active: c("#800080"),
Success: c("#008700"),
Error: c("#D70000"),
Syntax: "github",
}
}
// themeDracula follows the Dracula spec (draculatheme.com/spec): purple is the
// main accent, pink and yellow highlight, comment is muted text. The light
// variant is Dracula's official light palette, Alucard.
func themeDracula(isDark bool) Scheme {
if !isDark {
return Scheme{
Primary: c("#644AC9"), // purple
PrimaryMuted: c("#6C664B"), // comment
Accent: c("#A3144D"), // pink
Highlight: c("#846E15"), // yellow
Text: c("#1F1F1F"), // foreground
TextMuted: c("#6C664B"),
TextFaint: c("#6C664B"),
OnPrimary: c("#FFFBEB"), // background
Surface: c("#CFCFDE"), // selection
SurfaceAlt: c("#CFCFDE"),
BorderFocus: c("#644AC9"),
BorderBlur: c("#CFCFDE"),
Active: c("#A3144D"),
Success: c("#14710A"), // green
Error: c("#CB3A2A"), // red
Syntax: "github", // chroma has no Alucard style
}
}
return Scheme{
Primary: c("#BD93F9"), // purple
PrimaryMuted: c("#6272A4"), // comment
Accent: c("#FF79C6"), // pink
Highlight: c("#F1FA8C"), // yellow
Text: c("#F8F8F2"), // foreground
TextMuted: c("#6272A4"),
TextFaint: c("#6272A4"),
OnPrimary: c("#282A36"), // background
Surface: c("#44475A"), // current line / selection
SurfaceAlt: c("#44475A"),
BorderFocus: c("#BD93F9"),
BorderBlur: c("#44475A"),
Active: c("#FF79C6"),
Success: c("#50FA7B"), // green
Error: c("#FF5555"), // red
Syntax: "dracula",
IsDark: true,
}
}
// catppuccinScheme maps a Catppuccin flavor (catppuccin.com/palette) to the
// roles from its style guide (catppuccin.com/docs/style-guide): Base background,
// Mauve accent, Lavender active border, Overlay0 inactive border, Mantle bars.
func catppuccinScheme(f catppuccin.Flavour) Scheme {
h := func(col catppuccin.Color) color.Color { return c(col.Hex) }
return Scheme{
Primary: h(f.Mauve()),
PrimaryMuted: h(f.Overlay2()),
Accent: h(f.Blue()),
Highlight: h(f.Peach()),
Text: h(f.Text()),
TextMuted: h(f.Subtext0()),
TextFaint: h(f.Overlay1()),
OnPrimary: h(f.Crust()),
Surface: h(f.Mantle()),
SurfaceAlt: h(f.Surface0()),
BorderFocus: h(f.Lavender()),
BorderBlur: h(f.Overlay0()),
Active: h(f.Mauve()),
Success: h(f.Green()),
Error: h(f.Red()),
Background: h(f.Base()),
Syntax: "catppuccin-" + f.Name(),
IsDark: f.Name() != catppuccin.Latte.Name(),
}
}
// themeDesert is an earthy theme: muted sand gold and sage/olive green on
// warm brown (dark) or sand (light) backgrounds.
func themeDesert(isDark bool) Scheme {
if !isDark {
return Scheme{
Primary: c("#8A6D1F"), // mustard
PrimaryMuted: c("#A89466"),
Accent: c("#5B7A3A"), // olive
Highlight: c("#7A6A1E"),
Text: c("#3B3326"),
TextMuted: c("#6B604B"),
TextFaint: c("#8E8470"),
OnPrimary: c("#F5EFDF"),
Surface: c("#E6DCC3"),
SurfaceAlt: c("#D9CDB0"),
BorderFocus: c("#5B7A3A"),
BorderBlur: c("#D9CDB0"),
Active: c("#5B7A3A"),
Success: c("#4F7A2E"),
Error: c("#A3452F"), // terracotta
Syntax: "gruvbox-light",
}
}
return Scheme{
Primary: c("#C8A951"), // sand gold
PrimaryMuted: c("#8C7A4B"),
Accent: c("#8FA876"), // sage
Highlight: c("#B5B868"), // olive yellow
Text: c("#E8DFC8"),
TextMuted: c("#B3A88C"),
TextFaint: c("#857C66"),
OnPrimary: c("#2B2620"),
Surface: c("#3A342A"),
SurfaceAlt: c("#4A4335"),
BorderFocus: c("#8FA876"),
BorderBlur: c("#4A4335"),
Active: c("#6E8B4E"), // olive
Success: c("#7E9F5C"),
Error: c("#C0614A"), // terracotta
Background: c("#1C1813"), // dark warm brown
Syntax: "gruvbox",
IsDark: true,
}
}
// roles maps the YAML key of every color role to its field.
func (s *Scheme) roles() map[string]*color.Color {
return map[string]*color.Color{
"primary": &s.Primary,
"primaryMuted": &s.PrimaryMuted,
"accent": &s.Accent,
"highlight": &s.Highlight,
"text": &s.Text,
"textMuted": &s.TextMuted,
"textFaint": &s.TextFaint,
"onPrimary": &s.OnPrimary,
"surface": &s.Surface,
"surfaceAlt": &s.SurfaceAlt,
"borderFocus": &s.BorderFocus,
"borderBlur": &s.BorderBlur,
"active": &s.Active,
"success": &s.Success,
"error": &s.Error,
"background": &s.Background,
}
}
// themeFile is the format of ~/.config/ktea/themes/<name>.yaml.
type themeFile struct {
Base string `yaml:"base"`
Syntax string `yaml:"syntax"`
Dark map[string]string `yaml:"dark"`
Light map[string]string `yaml:"light"`
}
// Load resolves a theme by name: a built-in, or a user theme file
// <dir>/<name>.yaml. An empty name means the default theme.
func Load(name, dir string, isDark bool) (Scheme, error) {
if name == "" {
name = DefaultTheme
}
if fn, ok := builtins[name]; ok {
return fn(isDark), nil
}
path := filepath.Join(dir, name+".yaml")
f, err := os.Open(path)
if err != nil {
return Scheme{}, fmt.Errorf("theme %q: %w", name, err)
}
defer f.Close()
var tf themeFile
dec := yaml.NewDecoder(f)
dec.KnownFields(true)
if err := dec.Decode(&tf); err != nil && !errors.Is(err, io.EOF) {
return Scheme{}, fmt.Errorf("theme %q (%s): %w", name, path, err)
}
return tf.resolve(name, isDark)
}
func (tf themeFile) resolve(name string, isDark bool) (Scheme, error) {
base := tf.Base
if base == "" {
base = DefaultTheme
}
fn, ok := builtins[base]
if !ok {
return Scheme{}, fmt.Errorf("theme %q: unknown base %q (built-ins: %s)",
name, base, strings.Join(Builtins(), ", "))
}
s := fn(isDark)
overrides := tf.Dark
if !isDark && tf.Light != nil {
overrides = tf.Light
}
// Validate both sections so a typo is reported whatever the background.
for sec, m := range map[string]map[string]string{"dark": tf.Dark, "light": tf.Light} {
if err := validate(name, sec, m); err != nil {
return Scheme{}, err
}
}
roles := s.roles()
for k, v := range overrides {
*roles[k] = lipgloss.Color(v)
}
if tf.Syntax != "" {
if _, ok := chroma.Registry[tf.Syntax]; !ok {
return Scheme{}, fmt.Errorf("theme %q: unknown syntax style %q", name, tf.Syntax)
}
s.Syntax = tf.Syntax
}
return s, nil
}
func validate(name, section string, m map[string]string) error {
roles := (&Scheme{}).roles()
for k, v := range m {
if _, ok := roles[k]; !ok {
return fmt.Errorf("theme %q: unknown color %s.%s", name, section, k)
}
if lipgloss.Color(v) == (lipgloss.NoColor{}) {
return fmt.Errorf("theme %q: invalid color %s.%s: %q (use #RRGGBB or 0-255)", name, section, k, v)
}
}
return nil
}
package styles
// Popular editor themes, using each project's official palette. Every variant
// paints its own background and highlights payloads with the chroma style of
// the same name.
// fixed always uses s, whatever the terminal background.
func fixed(s Scheme) ThemeFunc { return func(bool) Scheme { return s } }
// adaptive picks dark or light from the terminal background.
func adaptive(dark, light Scheme) ThemeFunc {
return func(isDark bool) Scheme {
if isDark {
return dark
}
return light
}
}
// tokyoNight holds a Tokyo Night style (github.com/folke/tokyonight.nvim).
type tokyoNight struct {
bg, bgDark, bgHighlight, fg, fgDark, comment, fgGutter string
blue, cyan, magenta, orange, green, red string
}
func (p tokyoNight) scheme(style string, isDark bool) Scheme {
return Scheme{
Primary: c(p.blue),
PrimaryMuted: c(p.comment),
Accent: c(p.magenta),
Highlight: c(p.orange),
Text: c(p.fg),
TextMuted: c(p.fgDark),
TextFaint: c(p.comment),
OnPrimary: c(p.bgDark),
Surface: c(p.bgDark),
SurfaceAlt: c(p.bgHighlight),
BorderFocus: c(p.cyan),
BorderBlur: c(p.fgGutter),
Active: c(p.blue),
Success: c(p.green),
Error: c(p.red),
Background: c(p.bg),
Syntax: "tokyonight-" + style,
IsDark: isDark,
}
}
var (
tokyoNightNight = tokyoNight{
bg: "#1a1b26", bgDark: "#16161e", bgHighlight: "#292e42", fg: "#c0caf5", fgDark: "#a9b1d6",
comment: "#565f89", fgGutter: "#3b4261", blue: "#7aa2f7", cyan: "#7dcfff", magenta: "#bb9af7",
orange: "#ff9e64", green: "#9ece6a", red: "#f7768e",
}.scheme("night", true)
tokyoNightStorm = tokyoNight{
bg: "#24283b", bgDark: "#1f2335", bgHighlight: "#292e42", fg: "#c0caf5", fgDark: "#a9b1d6",
comment: "#565f89", fgGutter: "#3b4261", blue: "#7aa2f7", cyan: "#7dcfff", magenta: "#bb9af7",
orange: "#ff9e64", green: "#9ece6a", red: "#f7768e",
}.scheme("storm", true)
tokyoNightMoon = tokyoNight{
bg: "#222436", bgDark: "#1e2030", bgHighlight: "#2f334d", fg: "#c8d3f5", fgDark: "#828bb8",
comment: "#636da6", fgGutter: "#3b4261", blue: "#82aaff", cyan: "#86e1fc", magenta: "#c099ff",
orange: "#ff966c", green: "#c3e88d", red: "#ff757f",
}.scheme("moon", true)
tokyoNightDay = tokyoNight{
bg: "#e1e2e7", bgDark: "#d0d5e3", bgHighlight: "#c4c8da", fg: "#3760bf", fgDark: "#6172b0",
comment: "#848cb5", fgGutter: "#a8aecb", blue: "#2e7de9", cyan: "#007197", magenta: "#9854f1",
orange: "#b15c00", green: "#587539", red: "#f52a65",
}.scheme("day", false)
)
// gruvbox follows github.com/morhetz/gruvbox: orange and yellow on warm browns.
type gruvbox struct {
bg0, bg1, bg2, bg3, fg, fg4, gray string
red, green, yellow, aqua, orange, blue string
}
func (p gruvbox) scheme(syntax string, isDark bool) Scheme {
return Scheme{
Primary: c(p.orange),
PrimaryMuted: c(p.gray),
Accent: c(p.aqua),
Highlight: c(p.yellow),
Text: c(p.fg),
TextMuted: c(p.fg4),
TextFaint: c(p.gray),
OnPrimary: c(p.bg0),
Surface: c(p.bg1),
SurfaceAlt: c(p.bg2),
BorderFocus: c(p.yellow),
BorderBlur: c(p.bg3),
Active: c(p.blue),
Success: c(p.green),
Error: c(p.red),
Background: c(p.bg0),
Syntax: syntax,
IsDark: isDark,
}
}
var (
gruvboxDark = gruvbox{
bg0: "#282828", bg1: "#3c3836", bg2: "#504945", bg3: "#665c54", fg: "#ebdbb2", fg4: "#a89984",
gray: "#928374", red: "#fb4934", green: "#b8bb26", yellow: "#fabd2f", aqua: "#8ec07c",
orange: "#fe8019", blue: "#83a598",
}.scheme("gruvbox", true)
gruvboxLight = gruvbox{
bg0: "#fbf1c7", bg1: "#ebdbb2", bg2: "#d5c4a1", bg3: "#bdae93", fg: "#3c3836", fg4: "#7c6f64",
gray: "#928374", red: "#9d0006", green: "#79740e", yellow: "#b57614", aqua: "#427b58",
orange: "#af3a03", blue: "#076678",
}.scheme("gruvbox-light", false)
)
// nord follows nordtheme.com: Polar Night backgrounds, Frost accents.
var nord = Scheme{
Primary: c("#88c0d0"), // nord8
PrimaryMuted: c("#81a1c1"), // nord9
Accent: c("#8fbcbb"), // nord7
Highlight: c("#ebcb8b"), // nord13
Text: c("#eceff4"), // nord6
TextMuted: c("#d8dee9"), // nord4
TextFaint: c("#616e88"), // comments
OnPrimary: c("#2e3440"), // nord0
Surface: c("#3b4252"), // nord1
SurfaceAlt: c("#434c5e"), // nord2
BorderFocus: c("#88c0d0"),
BorderBlur: c("#4c566a"), // nord3
Active: c("#5e81ac"), // nord10
Success: c("#a3be8c"), // nord14
Error: c("#bf616a"), // nord11
Background: c("#2e3440"),
Syntax: "nord",
IsDark: true,
}
// rosePine follows rosepinetheme.com/palette.
type rosePine struct {
base, surface, overlay, muted, subtle, text string
love, gold, rose, pine, foam, iris, hlMed string
}
func (p rosePine) scheme(syntax string, isDark bool) Scheme {
return Scheme{
Primary: c(p.rose),
PrimaryMuted: c(p.subtle),
Accent: c(p.iris),
Highlight: c(p.gold),
Text: c(p.text),
TextMuted: c(p.subtle),
TextFaint: c(p.muted),
OnPrimary: c(p.base),
Surface: c(p.surface),
SurfaceAlt: c(p.overlay),
BorderFocus: c(p.iris),
BorderBlur: c(p.hlMed),
Active: c(p.pine),
Success: c(p.foam),
Error: c(p.love),
Background: c(p.base),
Syntax: syntax,
IsDark: isDark,
}
}
var (
rosePineMain = rosePine{
base: "#191724", surface: "#1f1d2e", overlay: "#26233a", muted: "#6e6a86", subtle: "#908caa",
text: "#e0def4", love: "#eb6f92", gold: "#f6c177", rose: "#ebbcba", pine: "#31748f",
foam: "#9ccfd8", iris: "#c4a7e7", hlMed: "#403d52",
}.scheme("rose-pine", true)
rosePineMoon = rosePine{
base: "#232136", surface: "#2a273f", overlay: "#393552", muted: "#6e6a86", subtle: "#908caa",
text: "#e0def4", love: "#eb6f92", gold: "#f6c177", rose: "#ea9a97", pine: "#3e8fb0",
foam: "#9ccfd8", iris: "#c4a7e7", hlMed: "#44415a",
}.scheme("rose-pine-moon", true)
rosePineDawn = rosePine{
base: "#faf4ed", surface: "#fffaf3", overlay: "#f2e9e1", muted: "#9893a5", subtle: "#797593",
text: "#575279", love: "#b4637a", gold: "#ea9d34", rose: "#d7827e", pine: "#286983",
foam: "#56949f", iris: "#907aa9", hlMed: "#dfdad9",
}.scheme("rose-pine-dawn", false)
)
// Solarized follows ethanschoonover.com/solarized: the dark and light modes
// swap the base tones and share the accents.
var (
solarizedDark = Scheme{
Primary: c("#268bd2"), // blue
PrimaryMuted: c("#6c71c4"), // violet
Accent: c("#2aa198"), // cyan
Highlight: c("#b58900"), // yellow
Text: c("#93a1a1"), // base1
TextMuted: c("#839496"), // base0
TextFaint: c("#586e75"), // base01
OnPrimary: c("#002b36"), // base03
Surface: c("#073642"), // base02
SurfaceAlt: c("#073642"),
BorderFocus: c("#93a1a1"),
BorderBlur: c("#586e75"),
Active: c("#268bd2"),
Success: c("#859900"), // green
Error: c("#dc322f"), // red
Background: c("#002b36"),
Syntax: "solarized-dark",
IsDark: true,
}
solarizedLight = Scheme{
Primary: c("#268bd2"),
PrimaryMuted: c("#6c71c4"),
Accent: c("#2aa198"),
Highlight: c("#b58900"),
Text: c("#586e75"), // base01
TextMuted: c("#657b83"), // base00
TextFaint: c("#93a1a1"), // base1
OnPrimary: c("#fdf6e3"), // base3
Surface: c("#eee8d5"), // base2
SurfaceAlt: c("#eee8d5"),
BorderFocus: c("#586e75"),
BorderBlur: c("#93a1a1"),
Active: c("#268bd2"),
Success: c("#859900"),
Error: c("#dc322f"),
Background: c("#fdf6e3"),
Syntax: "solarized-light",
}
)
// oneDark is Atom's One Dark (github.com/atom/one-dark-syntax).
var oneDark = Scheme{
Primary: c("#61afef"), // blue
PrimaryMuted: c("#5c6370"),
Accent: c("#c678dd"), // purple
Highlight: c("#e5c07b"), // yellow
Text: c("#abb2bf"), // mono-1
TextMuted: c("#828997"), // mono-2
TextFaint: c("#5c6370"), // mono-3
OnPrimary: c("#282c34"),
Surface: c("#21252b"),
SurfaceAlt: c("#3e4451"),
BorderFocus: c("#56b6c2"), // cyan
BorderBlur: c("#3e4451"),
Active: c("#61afef"),
Success: c("#98c379"), // green
Error: c("#e06c75"), // red
Background: c("#282c34"),
Syntax: "onedark",
IsDark: true,
}
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 (
"charm.land/lipgloss/v2"
"fmt"
"image/color"
"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 color.Color
inActiveColor color.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.Current.BorderFocus
if !m.Focused {
borderColor = styles.Current.BorderBlur
}
style := lipgloss.NewStyle().Foreground(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 color.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 {
return styles.EmbeddedBorderText(keyLabel, valueLabel)(active)
}
// 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.tabColor()).
Padding(0, 1).
Render(tab.Title)
} else {
style := lipgloss.NewStyle().Padding(0, 1)
if m.inActiveColor != nil {
style = style.Foreground(m.inActiveColor)
}
renderedTabs += style.Render(tab.Title)
}
}
return fmt.Sprintf("|%s|", renderedTabs)
}
}
}
// tabColor is read on every render so the border follows theme changes.
func (m *Model) tabColor() color.Color {
if m.activeColor != nil {
return m.activeColor
}
return styles.Current.Active
}
func WithInactiveColor(c color.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
for _, option := range options {
option(m)
}
return m
}
package chips
import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"fmt"
"image/color"
"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 color.Color
)
if m.activateElem == i {
if m.selectedIdx == i {
bgColor = styles.Current.Primary
} else {
bgColor = styles.Current.Text
}
style = lipgloss.NewStyle().
Background(bgColor).
Foreground(styles.Current.OnPrimary).
Bold(true)
elem = fmt.Sprintf("«%s»", elem)
} else if m.selectedIdx == i {
style = lipgloss.NewStyle().
Background(styles.Current.Primary).
Foreground(styles.Current.OnPrimary)
elem = fmt.Sprintf(" %s ", elem)
} else {
style = lipgloss.NewStyle().
Background(styles.Current.TextMuted).
Foreground(styles.Current.OnPrimary)
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.KeyPressMsg:
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 (
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/huh/v2"
"charm.land/lipgloss/v2"
"fmt"
"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(styles.Current.Text).
Render(lipgloss.NewStyle().MarginRight(2).Render("🗑️ " + s.deleteMsgFunc(s.deleteValue)))).
WithButtonAlignment(lipgloss.Left).
Focus()
s.deleteConfirm.WithTheme(styles.Huh)
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.KeyPressMsg:
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 (
"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"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 "charm.land/bubbletea/v2"
"charm.land/huh/v2"
"charm.land/log/v2"
"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(styles.Current.BorderFocus)
} else {
style = style.BorderForeground(styles.Current.BorderBlur)
}
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.KeyPressMsg:
return s.handleKeyMsg(msg)
}
return s.isActive(), msg, nil
}
func (s *SearchCmdBar) handleKeyMsg(msg tea.KeyPressMsg) (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.WithTheme(styles.Huh)
searchInput.Init()
return searchInput
}
func NewSearchCmdBar(placeholder string) *SearchCmdBar {
return &SearchCmdBar{
state: hidden,
searchInput: newSearchInput(placeholder),
placeholder: placeholder,
}
}
package cmdbar
import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"fmt"
"image/color"
"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 color.Color
render string
arrow string
)
if sort.Direction == Asc {
arrow = AscLabel
} else {
arrow = DescLabel
}
if m.activeIdx == i {
if m.selectedIdx == i {
bgColor = styles.Current.PrimaryMuted
} else {
bgColor = styles.Current.Text
}
style = lipgloss.NewStyle().
Background(bgColor).
Foreground(styles.Current.OnPrimary)
render = fmt.Sprintf(" %s %s ", sort.Label, arrow)
} else if m.selectedIdx == i {
style = lipgloss.NewStyle().
Background(styles.Current.Primary).
Foreground(styles.Current.OnPrimary)
render = fmt.Sprintf(" %s %s ", sort.Label, arrow)
} else {
style = lipgloss.NewStyle().
Background(styles.Current.SurfaceAlt).
Foreground(styles.Current.Text)
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.KeyPressMsg:
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(styles.Current.Primary).
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 "charm.land/bubbletea/v2"
)
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.KeyPressMsg:
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 help renders the keybindings modal shown on F1.
package help
import (
"ktea/styles"
"ktea/ui/components/statusbar"
"strings"
lg "charm.land/lipgloss/v2"
)
// chromeHeight is the number of lines the modal uses besides the shortcut rows:
// border (2), vertical padding (2), title + blank line (2), blank line + footer (2).
const chromeHeight = 8
const columnGap = " "
// View renders the keybindings modal. Shortcuts are laid out in as many
// columns as needed to keep the modal within maxHeight.
func View(title string, shortcuts []statusbar.Shortcut, maxHeight int) string {
heading := "Keybindings"
if title != "" {
heading += " · " + title
}
rowsPerColumn := max(1, maxHeight-chromeHeight)
var columns []string
for start := 0; start < len(shortcuts); start += rowsPerColumn {
end := min(start+rowsPerColumn, len(shortcuts))
columns = append(columns, renderColumn(shortcuts[start:end]))
}
var body []string
for i, c := range columns {
if i > 0 {
body = append(body, columnGap)
}
body = append(body, c)
}
return styles.Help.Modal.Render(lg.JoinVertical(lg.Left,
styles.Help.Title.Render(heading),
"",
lg.JoinHorizontal(lg.Top, body...),
"",
styles.Help.Footer.Render("esc / F1 close"),
))
}
func renderColumn(shortcuts []statusbar.Shortcut) string {
keyWidth := 0
for _, s := range shortcuts {
keyWidth = max(keyWidth, lg.Width(s.Keybinding))
}
rows := make([]string, len(shortcuts))
for i, s := range shortcuts {
pad := strings.Repeat(" ", keyWidth-lg.Width(s.Keybinding))
rows[i] = pad + styles.Help.Key.Render(s.Keybinding) + " " + styles.Help.Desc.Render(s.Name)
}
return lg.JoinVertical(lg.Left, rows...)
}
package notifier
import (
"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
lg "charm.land/lipgloss/v2"
"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.Current.Error).Render(msg+s) +
styles.FG(styles.Current.Text).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.Current.Error).Render(split[0]) + ": " +
styles.FG(styles.Current.Text).Render(strings.TrimSuffix(split[1], "\n"))
} else {
m.msg = "🚨 " + styles.FG(styles.Current.Error).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 (
lg "charm.land/lipgloss/v2"
"image/color"
"ktea/kontext"
"ktea/styles"
"ktea/ui"
)
type Model struct {
provider Provider
}
type Provider interface {
Shortcuts() []Shortcut
Title() string
}
type UpdateMsg struct {
StatusBar Provider
}
type Shortcut struct {
Name string
Keybinding string
}
func (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, lg.Color(clusterColor), styles.Current.Primary)
clusterLeft := renderRune(leftSeparator, lg.Color(clusterColor), lg.NoColor{})
activeCluster = clusterLeft + activeCluster + clusterRight
}
indicator := styles.Statusbar.Indicator.Render(m.provider.Title())
endSeparator := "\uE0B4"
if ktx.Config().PlainFonts {
endSeparator = ""
}
indicator += renderRune(endSeparator, styles.Current.Primary, styles.Current.Surface)
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.Current.Surface),
))
return renderer.Render(barView)
}
func renderRune(symbol string, fg, bg color.Color) string {
return lg.NewStyle().
Foreground(fg).
Background(bg).
Render(symbol)
}
func renderText(text string, fg color.Color) string {
return lg.NewStyle().
Foreground(fg).
Render(text)
}
// Shortcuts returns the global shortcuts followed by those of the active provider.
func (m *Model) Shortcuts() []Shortcut {
shortcuts := []Shortcut{{Name: "Switch Tabs", Keybinding: "C-←/→/h/l"}}
if m.provider != nil {
shortcuts = append(shortcuts, m.provider.Shortcuts()...)
}
return shortcuts
}
func (m *Model) Title() string {
if m.provider == nil {
return ""
}
return m.provider.Title()
}
func (m *Model) SetProvider(provider Provider) {
m.provider = provider
}
func New() *Model {
return &Model{}
}
package tab
import (
tea "charm.land/bubbletea/v2"
lg "charm.land/lipgloss/v2"
"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: "≪ F1 » help", Label: "help"}
// helpTitle colors the F1 key at render time so it follows theme changes.
func helpTitle() string {
return lg.NewStyle().Foreground(styles.Current.Highlight).Render("≪ F1 »") + " 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(helpTitle())
} 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.KeyPressMsg:
switch msg.String() {
case "ctrl+left", "ctrl+h":
m.Prev()
case "ctrl+right", "ctrl+l":
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 (
"charm.land/bubbles/v2/table"
"ktea/styles"
)
func NewDefaultTable() table.Model {
return table.New(
table.WithFocused(true),
table.WithStyles(styles.Table.Styles),
)
}
// View renders t with the current theme's table styles.
// Tables copy their styles when created, so re-apply them to follow theme changes.
func View(t *table.Model) string {
t.SetStyles(styles.Table.Styles)
return t.View()
}
// RowWidth is the rendered width of a row with the given columns.
// bubbles v2 tables clip rows to their width, which defaults to 0.
func RowWidth(cols []table.Column) int {
cellPadding := styles.Table.Styles.Cell.GetHorizontalFrameSize()
w := 0
for _, c := range cols {
w += c.Width + cellPadding
}
return w
}
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"
"ktea/styles"
"strings"
)
func PrettyPrintJson(text string) string {
builder := &strings.Builder{}
formatter := formatters.TTY256
style := chrome_styles.Get(styles.Current.Syntax)
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 "charm.land/lipgloss/v2"
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 ui
import "charm.land/lipgloss/v2"
// Overlay draws modal centered on top of base, within an area of width x height.
func Overlay(base, modal string, width, height int) string {
x := max(0, (width-lipgloss.Width(modal))/2)
y := max(0, (height-lipgloss.Height(modal))/2)
return lipgloss.NewCompositor(
lipgloss.NewLayer(base),
lipgloss.NewLayer(modal).X(x).Y(y).Z(1),
).Render()
}
package cgroups_page
import (
"charm.land/log/v2"
"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"
"reflect"
"slices"
"sort"
"strconv"
"strings"
"charm.land/bubbles/v2/table"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
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(ktable.View(&m.table)))
}
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.KeyPressMsg:
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(styles.Current.Primary).
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(styles.Current.Accent).
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 (
"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
"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 "charm.land/bubbletea/v2"
"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.KeyPressMsg:
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"
ktable "ktea/ui/components/table"
"ktea/ui/pages/nav"
"reflect"
"slices"
"sort"
"strconv"
"strings"
"charm.land/log/v2"
"charm.land/bubbles/v2/table"
tea "charm.land/bubbletea/v2"
lg "charm.land/lipgloss/v2"
"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)
m.offsetsTable.SetWidth(ktable.RowWidth(m.offsetsTable.Columns()))
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(ktable.View(&m.topicsTable), topicTableStyle),
)
offsetsView := m.offsetsBorder.View(
renderer.RenderWithStyle(ktable.View(&m.offsetsTable), 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.KeyPressMsg:
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 (
"charm.land/bubbles/v2/table"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"charm.land/log/v2"
"fmt"
"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(ktable.View(m.table)))
}
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.KeyPressMsg:
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(styles.Current.Accent).
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 (
"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
"charm.land/huh/v2"
"charm.land/lipgloss/v2"
"fmt"
"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.CmdBarWithWidth(ktx.WindowWidth-2)))
} else if m.state == EDITING {
views = append(views, renderer.RenderWithStyle(m.editInput.View(), styles.CmdBarWithWidth(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.CmdBarWithWidth(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.KeyPressMsg:
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.WithTheme(styles.Huh)
searchInput.Init()
return searchInput
}
func newEditInput(v string) *huh.Input {
searchInput := huh.NewInput().
Value(&v).
Placeholder("New config value")
searchInput.WithTheme(styles.Huh)
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"
ktable "ktea/ui/components/table"
"sort"
"strings"
"charm.land/bubbles/v2/table"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
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.SetWidth(ktable.RowWidth(m.table.Columns()))
m.table.SetRows(m.rows)
m.table.Focus()
return ui.JoinVertical(lipgloss.Top, cmdBarView, m.border.View(ktable.View(m.table)))
}
func (m *Model) Update(msg tea.Msg) tea.Cmd {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyPressMsg:
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_page
import (
tea "charm.land/bubbletea/v2"
"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
}
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.KeyPressMsg:
switch msg.String() {
case "f3":
active, _, cmd := c.sortByCBar.Update(msg)
if !active {
c.active = nil
} else {
c.active = c.sortByCBar
}
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) 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,
}
}
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"
ktable "ktea/ui/components/table"
"ktea/ui/pages"
"ktea/ui/tabs"
"sort"
"strconv"
"charm.land/bubbles/v2/table"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
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
navigator tabs.TopicsTabNavigator
search *searchModal
// readStarted is false while the page waits for a search to be submitted
readStarted bool
// source identifies the current consumption, see isStale
source chan kadmin.ConsumerRecord
goToTop bool
}
// isStale reports whether msg belongs to a consumption that has been replaced
// by a newer search.
func (m *Model) isStale(msg tea.Msg) bool {
var source chan kadmin.ConsumerRecord
switch msg := msg.(type) {
case kadmin.ConsumerRecordReceived:
source = msg.Source()
case kadmin.ConsumptionEndedMsg:
source = msg.Source()
}
return source != nil && source != m.source
}
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)
// a new search starts at the top; the cursor needs rows to move
if m.goToTop && len(m.rows) > 0 {
m.table.GotoTop()
m.goToTop = false
}
m.table.SetWidth(ktx.WindowWidth - 2)
m.table.SetHeight(ktx.AvailableTableHeight())
views = append(views, m.border.View(ktable.View(m.table)))
}
content := ui.JoinVertical(lipgloss.Top, views...)
if m.search != nil {
content = ui.Overlay(content, m.search.View(ktx.WindowWidth), ktx.WindowWidth, ktx.AvailableHeight)
}
return content
}
func (m *Model) Update(msg tea.Msg) tea.Cmd {
var cmds []tea.Cmd
switch msg := msg.(type) {
case searchClosedMsg:
m.search = nil
if !m.readStarted {
return m.navigator.ToTopicsPage()
}
return nil
case searchSubmittedMsg:
m.search = nil
return m.startReading(msg.details)
}
if m.isStale(msg) {
return nil
}
// the search modal captures all key presses while open, other messages
// still reach the page so a running consumption keeps going
if m.search != nil {
if _, ok := msg.(tea.KeyPressMsg); ok {
return m.search.Update(msg)
}
cmds = append(cmds, m.search.Update(msg))
}
switch msg := msg.(type) {
case tea.KeyPressMsg:
if msg.String() == "esc" {
m.cancelConsumption()
return m.navigator.ToTopicsPage()
} else if (msg.String() == "/" || msg.String() == "ctrl+f") && !m.cmdBar.IsFocussed() {
m.search = newSearchModal(m.topic, m.readDetails, msg.String() == "ctrl+f")
return nil
} 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.source = msg.Source()
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
}
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.search != nil {
return m.search.Shortcuts()
}
if m.consuming {
return []statusbar.Shortcut{
{"View Record", "enter"},
{"Stop consuming", "F2"},
{"Go Back", "esc"},
}
} else if m.noRecordsAvailable || m.noRecordsFound {
return []statusbar.Shortcut{
{"Search", "/"},
{"Advanced Search", "C-f"},
{"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"},
{"Search", "/"},
{"Advanced Search", "C-f"},
{"Sort", "F3"},
{"Go Back", "esc"},
}
}
func (m *Model) Title() string {
return "Topics / " + m.readDetails.TopicName + " / Records"
}
// New creates the records page. With openSearch it opens the expanded search
// modal and waits for it to be submitted instead of reading right away.
func New(
reader kadmin.RecordReader,
readDetails kadmin.ReadDetails,
topic *kadmin.ListedTopic,
openSearch bool,
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.cancelConsumption = func() {}
m.border = border.New(
border.WithInnerPaddingTop(),
border.WithTitleFn(func() string {
return border.KeyValueTitle("Records", fmt.Sprintf(" %d", len(m.rows)), true)
}))
if openSearch {
m.search = newSearchModal(topic, readDetails, true)
return m, nil
}
return m, m.startReading(readDetails)
}
// startReading cancels any running consumption and reads the topic anew.
func (m *Model) startReading(details kadmin.ReadDetails) tea.Cmd {
m.cancelConsumption()
ctx, cancelFn := context.WithCancel(context.Background())
m.cancelConsumption = cancelFn
m.readDetails = details
m.readStarted = true
m.source = nil
m.records = nil
m.rows = nil
m.noRecordsAvailable = false
m.noRecordsFound = false
m.goToTop = true
return func() tea.Msg {
msg := m.reader.ReadRecords(ctx, details)
if ctx.Err() != nil {
// replaced by a newer search while starting
return nil
}
return msg
}
}
package consume_page
import (
"fmt"
"ktea/kadmin"
"ktea/styles"
"ktea/ui/components/statusbar"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/huh/v2"
lg "charm.land/lipgloss/v2"
)
type startPoint int
const (
mostRecent startPoint = iota
beginning
live
relativeDate
absoluteDate
)
const maxModalWidth = 72
// searchValues holds what the user entered; the huh fields write into it.
type searchValues struct {
term string
keyFilter kadmin.FilterType
keyTerm string
valueFilter kadmin.FilterType
valueTerm string
startFrom startPoint
relativeStartPoint kadmin.StartPoint
absoluteStartPoint string
partitions []int
limit int
}
// searchTab groups the fields shown under one tab header.
type searchTab struct {
name string
fields []huh.Field
}
// searchModal searches a topic. In simple mode it only asks for a term matched
// against key or value; expanded, it offers tabs to refine filters, start point,
// partitions and limit. Every tab is a huh group, so tab/shift+tab walk across them.
type searchModal struct {
topic *kadmin.ListedTopic
values searchValues
expanded bool
form *huh.Form
tabs []searchTab
err string
}
func newSearchModal(topic *kadmin.ListedTopic, details kadmin.ReadDetails, expanded bool) *searchModal {
m := &searchModal{
topic: topic,
values: toSearchValues(topic, details),
expanded: expanded,
}
m.buildForm()
return m
}
func toSearchValues(topic *kadmin.ListedTopic, details kadmin.ReadDetails) searchValues {
v := searchValues{
keyFilter: kadmin.ContainsFilterType,
valueFilter: kadmin.ContainsFilterType,
startFrom: toFormStartPoint(details.StartPoint),
relativeStartPoint: kadmin.Today,
absoluteStartPoint: toAbsoluteStartPoint(details.StartPoint),
limit: details.Limit,
}
if v.startFrom == relativeDate {
v.relativeStartPoint = details.StartPoint
}
// all partitions is shown as none selected
if len(details.PartitionToRead) != topic.PartitionCount {
v.partitions = details.PartitionToRead
}
if f := details.Filter; f != nil {
v.term = f.Term
if f.KeyFilter == kadmin.ContainsFilterType || f.KeyFilter == kadmin.StartsWithFilterType {
v.keyFilter, v.keyTerm = f.KeyFilter, f.KeySearchTerm
}
if f.ValueFilter == kadmin.ContainsFilterType || f.ValueFilter == kadmin.StartsWithFilterType {
v.valueFilter, v.valueTerm = f.ValueFilter, f.ValueSearchTerm
}
}
return v
}
func (m *searchModal) buildForm() {
v := &m.values
m.tabs = []searchTab{{name: "Key or Value", fields: []huh.Field{
huh.NewInput().
Value(&v.term).
Title("Search").
Description("Matches records whose key or value contains the term (ignoring case)"),
}}}
if m.expanded {
var partOptions []huh.Option[int]
for i := range m.topic.PartitionCount {
partOptions = append(partOptions, huh.NewOption(fmt.Sprintf("Partition %d", i), i))
}
m.tabs = append(m.tabs,
searchTab{name: "Key", fields: []huh.Field{
filterTypeField(&v.keyFilter, "Key filter"),
huh.NewInput().Value(&v.keyTerm).Title("Key term").Description("Leave empty to not filter on key"),
}},
searchTab{name: "Value", fields: []huh.Field{
filterTypeField(&v.valueFilter, "Value filter"),
huh.NewInput().Value(&v.valueTerm).Title("Value term").Description("Leave empty to not filter on value"),
}},
searchTab{name: "Start", fields: []huh.Field{
huh.NewSelect[startPoint]().
Value(&v.startFrom).
Title("Start from").
Options(
huh.NewOption("Most Recent", mostRecent),
huh.NewOption("Beginning", beginning),
huh.NewOption("Live", live),
huh.NewOption("Relative Date", relativeDate),
huh.NewOption("Absolute Date", absoluteDate)),
huh.NewSelect[kadmin.StartPoint]().
Value(&v.relativeStartPoint).
Title("Relatively start from").
Options(
huh.NewOption("Today", kadmin.Today),
huh.NewOption("Yesterday", kadmin.Yesterday),
huh.NewOption("Week ago", kadmin.Last7Days)),
huh.NewInput().
Value(&v.absoluteStartPoint).
Title("Absolutely start from").
Description("format(RFC3339): 1986-01-16T23:20:50.52Z").
Validate(validateAbsoluteDate),
}},
searchTab{name: "Partitions", fields: []huh.Field{
huh.NewMultiSelect[int]().
Value(&v.partitions).
Title("Partitions").
Description("Select none to consume from all partitions").
Height(min(len(partOptions)+2, 12)).
Options(partOptions...),
}},
searchTab{name: "Limit", fields: []huh.Field{
huh.NewSelect[int]().
Value(&v.limit).
Title("Limit").
Options(
huh.NewOption("50", 50),
huh.NewOption("500", 500),
huh.NewOption("5000", 5000)),
}},
)
}
var groups []*huh.Group
for _, t := range m.tabs {
if t.name != "Start" {
groups = append(groups, huh.NewGroup(t.fields...))
continue
}
// the date sub-fields get their own group so they only show when relevant
groups = append(groups,
huh.NewGroup(t.fields[0]),
huh.NewGroup(t.fields[1]).WithHideFunc(func() bool { return v.startFrom != relativeDate }),
huh.NewGroup(t.fields[2]).WithHideFunc(func() bool { return v.startFrom != absoluteDate }),
)
}
m.form = huh.NewForm(groups...).WithTheme(styles.Huh).WithShowHelp(false)
m.form.Init()
}
func filterTypeField(value *kadmin.FilterType, title string) *huh.Select[kadmin.FilterType] {
return huh.NewSelect[kadmin.FilterType]().
Value(value).
Title(title).
Options(
huh.NewOption("Contains", kadmin.ContainsFilterType),
huh.NewOption("Starts With", kadmin.StartsWithFilterType))
}
func validateAbsoluteDate(v string) error {
if _, err := time.Parse(time.RFC3339, v); err != nil {
return fmt.Errorf("invalid date time format")
}
return nil
}
// searchSubmittedMsg is returned by the modal when the user confirms the search.
type searchSubmittedMsg struct {
details kadmin.ReadDetails
}
// searchClosedMsg is returned by the modal when the user cancels.
type searchClosedMsg struct{}
func (m *searchModal) Update(msg tea.Msg) tea.Cmd {
if key, ok := msg.(tea.KeyPressMsg); ok {
m.err = ""
switch key.String() {
case "esc":
return func() tea.Msg { return searchClosedMsg{} }
case "ctrl+f":
m.expanded = !m.expanded
m.buildForm()
return nil
case "enter":
details, err := m.readDetails()
if err != nil {
m.err = err.Error()
return nil
}
return func() tea.Msg { return searchSubmittedMsg{details} }
case "tab":
// moving past the last field would complete the form
if m.isLastField() {
return nil
}
}
}
form, cmd := m.form.Update(msg)
if f, ok := form.(*huh.Form); ok {
m.form = f
}
return cmd
}
func (m *searchModal) isLastField() bool {
last := m.tabs[len(m.tabs)-1].fields
return m.form.GetFocusedField() == last[len(last)-1]
}
func (m *searchModal) activeTab() int {
focused := m.form.GetFocusedField()
for i, t := range m.tabs {
for _, f := range t.fields {
if f == focused {
return i
}
}
}
return 0
}
func (m *searchModal) readDetails() (kadmin.ReadDetails, error) {
v := m.values
filter := &kadmin.Filter{Term: strings.TrimSpace(v.term)}
if v.keyTerm != "" {
filter.KeyFilter, filter.KeySearchTerm = v.keyFilter, v.keyTerm
}
if v.valueTerm != "" {
filter.ValueFilter, filter.ValueSearchTerm = v.valueFilter, v.valueTerm
}
sp, err := m.startPoint()
if err != nil {
return kadmin.ReadDetails{}, err
}
partitions := v.partitions
if len(partitions) == 0 {
partitions = m.topic.Partitions()
}
return kadmin.ReadDetails{
TopicName: m.topic.Name,
PartitionToRead: partitions,
StartPoint: sp,
Limit: v.limit,
Filter: filter,
}, nil
}
func (m *searchModal) startPoint() (kadmin.StartPoint, error) {
switch m.values.startFrom {
case beginning:
return kadmin.Beginning, nil
case live:
return kadmin.Live, nil
case relativeDate:
return m.values.relativeStartPoint, nil
case absoluteDate:
t, err := time.Parse(time.RFC3339, m.values.absoluteStartPoint)
if err != nil {
return 0, fmt.Errorf("invalid absolute start date, expected RFC3339")
}
return kadmin.StartPoint(t.UnixMilli()), nil
default:
return kadmin.MostRecent, nil
}
}
func toFormStartPoint(sp kadmin.StartPoint) startPoint {
switch sp {
case kadmin.Beginning:
return beginning
case kadmin.MostRecent:
return mostRecent
case kadmin.Live:
return live
case kadmin.Today, kadmin.Yesterday, kadmin.Last7Days:
return relativeDate
default:
return absoluteDate
}
}
func toAbsoluteStartPoint(sp kadmin.StartPoint) string {
if toFormStartPoint(sp) != absoluteDate {
return ""
}
return time.UnixMilli(int64(sp)).Format(time.RFC3339)
}
// View renders the modal sized to fit within the given window width.
func (m *searchModal) View(windowWidth int) string {
width := min(maxModalWidth, max(20, windowWidth-8))
m.form.WithWidth(width)
var header []string
active := m.activeTab()
for i, t := range m.tabs {
name := t.name
if m.isChanged(t.name) {
name += changedMarker
}
if i == active {
header = append(header, styles.Help.Key.Render("["+name+"]"))
} else {
header = append(header, styles.Help.Footer.Render(" "+name+" "))
}
}
footer := "enter search · esc close · ctrl+f more options"
if n := m.hiddenChanges(); n > 0 {
footer += fmt.Sprintf(" (%d set)", n)
}
if m.expanded {
footer = "enter search · tab next · esc close · ctrl+f fewer options"
}
parts := []string{
styles.Help.Title.Render("Search " + m.topic.Name),
"",
lg.NewStyle().Width(width).Render(strings.Join(header, " ")),
"",
m.form.View(),
}
if m.err != "" {
parts = append(parts, styles.Notifier.Error.UnsetMarginLeft().Render(m.err))
}
parts = append(parts, "", styles.Help.Footer.Width(width).Render(footer))
return styles.Help.Modal.Render(lg.JoinVertical(lg.Left, parts...))
}
const changedMarker = " ●"
// advancedTabs are the tabs only shown when the modal is expanded.
var advancedTabs = []string{"Key", "Value", "Start", "Partitions", "Limit"}
// isChanged reports whether the tab differs from the default search.
func (m *searchModal) isChanged(tab string) bool {
v := m.values
def := kadmin.NewDefaultReadDetails(m.topic)
switch tab {
case "Key or Value":
return strings.TrimSpace(v.term) != ""
case "Key":
return v.keyTerm != ""
case "Value":
return v.valueTerm != ""
case "Start":
return v.startFrom != toFormStartPoint(def.StartPoint)
case "Partitions":
return len(v.partitions) > 0
case "Limit":
return v.limit != def.Limit
}
return false
}
// hiddenChanges counts the advanced tabs that differ from the default search
// while the modal is collapsed.
func (m *searchModal) hiddenChanges() int {
if m.expanded {
return 0
}
n := 0
for _, t := range advancedTabs {
if m.isChanged(t) {
n++
}
}
return n
}
func (m *searchModal) Shortcuts() []statusbar.Shortcut {
toggle := statusbar.Shortcut{Name: "More Options", Keybinding: "C-f"}
if m.expanded {
toggle.Name = "Fewer Options"
}
shortcuts := []statusbar.Shortcut{
{Name: "Search", Keybinding: "enter"},
{Name: "Close", Keybinding: "esc"},
toggle,
}
if m.expanded {
shortcuts = append(shortcuts, statusbar.Shortcut{Name: "Next/Prev Field", Keybinding: "tab/S-tab"})
}
return shortcuts
}
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 "charm.land/bubbletea/v2"
"charm.land/huh/v2"
"charm.land/lipgloss/v2"
"charm.land/log/v2"
)
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.KeyPressMsg:
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.Current.TextMuted)
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),
).WithTheme(styles.Huh)
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),
).WithTheme(styles.Huh)
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.Current.SurfaceAlt))
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.Current.TextMuted))
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 (
"charm.land/bubbles/v2/table"
tea "charm.land/bubbletea/v2"
"charm.land/huh/v2"
"charm.land/lipgloss/v2"
"charm.land/log/v2"
"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(ktable.View(&m.table)))
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.KeyPressMsg:
switch msg.String() {
case "esc":
if m.state == entering {
if len(m.connectClusters) > 0 {
m.state = listing
}
} else {
m.navigator.ToClustersPage()
}
return nil
case "f2":
clusterName := m.table.SelectedRow()[0]
m.deleteCmdbar.Delete(clusterName)
_, _, cmd := m.deleteCmdbar.Update(msg)
return cmd
case "ctrl+n":
m.state = entering
m.resetFormValues()
m.form = m.createKcForm()
case "ctrl+e":
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"),
).WithTheme(styles.Huh)
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 (
tea "charm.land/bubbletea/v2"
"charm.land/huh/v2"
"charm.land/lipgloss/v2"
"fmt"
"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.KeyPressMsg:
{
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,
)).WithTheme(styles.Huh)
form.Init()
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 "charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
"charm.land/huh/v2"
"charm.land/lipgloss/v2"
)
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.KeyPressMsg:
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,
)).WithTheme(styles.Huh)
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 (
"charm.land/bubbles/v2/table"
tea "charm.land/bubbletea/v2"
"charm.land/log/v2"
"fmt"
"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(ktable.View(&m.table)))
}
func (m *Model) Update(msg tea.Msg) tea.Cmd {
log.Debug("Received Update", "msg", reflect.TypeOf(msg))
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch msg.Code {
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 (
"charm.land/bubbles/v2/spinner"
"charm.land/bubbles/v2/table"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"charm.land/log/v2"
"fmt"
"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(ktable.View(m.table))
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.KeyPressMsg:
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(styles.Current.Accent).
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 (
"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
"charm.land/huh/v2"
"charm.land/lipgloss/v2"
"charm.land/log/v2"
"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"
"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.KeyPressMsg:
switch msg.String() {
case "esc":
if m.state == publishing {
return nil
}
return ui.PublishMsg(nav.LoadTopicsPageMsg{})
case "ctrl+r":
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(""),
),
).WithTheme(styles.Huh)
form.WithLayout(huh.LayoutGrid(4, 2))
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"
"charm.land/bubbles/v2/table"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"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.KeyPressMsg:
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(viewport.WithWidth(width), viewport.WithHeight(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.SetHeight(height)
m.schemaVp.SetWidth(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(viewport.WithWidth(sideBarWidth), viewport.WithHeight(height-headerValueTableHeight-4))
m.headerValueVp = &headerValueVp
m.headerKeyTable.SetColumns([]table.Column{
{"Header Key", sideBarWidth},
})
m.headerKeyTable.SetHeight(headerValueTableHeight)
m.headerKeyTable.SetWidth(ktable.RowWidth(m.headerKeyTable.Columns()))
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, ktable.View(m.headerKeyTable), 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(viewport.WithWidth(payloadWidth), viewport.WithHeight(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(styles.Current.TextMuted).
Render("Unable to render payload")))
}
} else {
m.recordVp.SetHeight(height)
m.recordVp.SetWidth(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(styles.Current.BorderBlur)
} else {
headersTableStyle = lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
Padding(0).
Margin(0).
BorderForeground(styles.Current.BorderFocus)
}
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 (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"fmt"
"image/color"
"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.KeyPressMsg:
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.Current.Accent) +
renderFG(fmt.Sprintf("%d", schemaId), styles.Current.Text) +
renderFG(" of schema?", styles.Current.Accent)
}
return &CmdBar{
notifierWidget: notifierCmdBar,
active: notifierCmdBar,
deleteCmdBar: cmdbar.NewDeleteCmdBar[int](deleteMsgFunc, deleteFunc),
}
}
func renderFG(value string, c color.Color) string {
return lipgloss.NewStyle().
Foreground(c).
Bold(true).
Render(value)
}
package schema_details_page
import (
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"fmt"
"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(viewport.WithWidth(ktx.WindowWidth-3), viewport.WithHeight(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.SetHeight(ktx.AvailableHeight - 5)
m.vp.SetWidth(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.KeyPressMsg:
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 settings_page
import (
"fmt"
"ktea/kontext"
"ktea/styles"
"ktea/ui"
"ktea/ui/components/border"
"ktea/ui/components/statusbar"
ktable "ktea/ui/components/table"
"charm.land/bubbles/v2/table"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
// Model lists the available themes. Enter applies the selected theme
// immediately and saves it to the config.
type Model struct {
ktx *kontext.ProgramKtx
themesDir string
// isDark is the terminal's background, not the active theme's: a fixed
// theme like catppuccin-latte must not make the next theme load its light variant.
isDark bool
themes []string
table table.Model
border *border.Model
err string
}
func (m *Model) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
current := m.currentTheme()
rows := make([]table.Row, len(m.themes))
for i, name := range m.themes {
marker, kind := "", "custom"
if name == current {
marker = "●"
}
if styles.IsBuiltin(name) {
kind = "built-in"
}
rows[i] = table.Row{marker, name, kind}
}
height := ktx.AvailableHeight - 2
if m.err != "" {
height--
}
m.table.SetColumns([]table.Column{
{Title: "", Width: 2},
{Title: "Theme", Width: max(ktx.WindowWidth-20, 10)},
{Title: "Type", Width: 10},
})
m.table.SetWidth(ktx.WindowWidth - 2)
m.table.SetHeight(height)
m.table.SetRows(rows)
view := m.border.View(ktable.View(&m.table))
if m.err != "" {
view = lipgloss.JoinVertical(lipgloss.Left, view, styles.FG(styles.Current.Error).Render(" "+m.err))
}
return renderer.Render(view)
}
func (m *Model) Update(msg tea.Msg) tea.Cmd {
switch msg := msg.(type) {
case ui.RegainedFocusMsg:
// pick up theme files added while ktea runs
m.themes = styles.Available(m.themesDir)
return nil
case tea.KeyPressMsg:
if msg.Code == tea.KeyEnter {
m.apply()
return nil
}
}
var cmd tea.Cmd
m.table, cmd = m.table.Update(msg)
return cmd
}
func (m *Model) apply() {
row := m.table.SelectedRow()
if row == nil {
return
}
name := row[1]
s, err := styles.Load(name, m.themesDir, m.isDark)
if err != nil {
m.err = err.Error()
return
}
m.err = ""
styles.Apply(s)
m.ktx.Config().SetTheme(name)
}
func (m *Model) currentTheme() string {
if t := m.ktx.Config().Theme; t != "" {
return t
}
return styles.DefaultTheme
}
func (m *Model) Shortcuts() []statusbar.Shortcut {
return []statusbar.Shortcut{
{Name: "Apply Theme", Keybinding: "enter"},
}
}
func (m *Model) Title() string {
return "Settings"
}
func New(ktx *kontext.ProgramKtx, themesDir string, isDark bool) *Model {
m := &Model{
ktx: ktx,
themesDir: themesDir,
isDark: isDark,
themes: styles.Available(themesDir),
table: ktable.NewDefaultTable(),
}
m.border = border.New(border.WithTitleFn(func() string {
return border.KeyValueTitle("Themes", fmt.Sprintf("%d", len(m.themes)), true)
}))
// start the cursor on the active theme
for i, name := range m.themes {
if name == m.currentTheme() {
m.table.SetCursor(i)
}
}
return m
}
package subjects_page
import (
"charm.land/log/v2"
"fmt"
"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"
"charm.land/bubbles/v2/table"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
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(ktable.View(&m.table)))
}
func (m *Model) columnTitle(title string) string {
if m.sort.Label == title {
return lipgloss.NewStyle().
Foreground(styles.Current.Primary).
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.KeyPressMsg:
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(styles.Current.Accent).
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 "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"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.KeyPressMsg:
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(styles.Current.Accent).
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"
"charm.land/bubbles/v2/spinner"
"charm.land/bubbles/v2/table"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"charm.land/log/v2"
)
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(ktable.View(&m.table)))
}
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.KeyPressMsg:
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+f":
// only accept enter when the table is focussed
if !m.tcb.IsFocussed() {
if m.SelectedTopic() != nil {
return m.navigator.ToConsumePage(tabs.ConsumePageDetails{
Topic: m.SelectedTopic(),
ReadDetails: kadmin.NewDefaultReadDetails(m.SelectedTopic()),
OpenSearch: true,
})
}
}
case "enter":
// only accept enter when the table is focussed
if !m.tcb.IsFocussed() {
if m.SelectedTopic() != nil {
return m.navigator.ToConsumePage(tabs.ConsumePageDetails{
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"},
{"Advanced Search", "C-f"},
{"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(styles.Current.Accent).
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 "charm.land/bubbletea/v2"
)
func PublishMsg(msg tea.Msg) tea.Cmd {
return func() tea.Msg {
return msg
}
}
package ui
import (
"charm.land/lipgloss/v2"
"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 "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"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 "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
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.KeyPressMsg:
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 "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"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 "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"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 settings_tab
import (
"ktea/kontext"
"ktea/ui"
"ktea/ui/components/statusbar"
"ktea/ui/pages/settings_page"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
type Model struct {
page *settings_page.Model
statusbar *statusbar.Model
}
func (m *Model) View(ktx *kontext.ProgramKtx, renderer *ui.Renderer) string {
return ui.JoinVertical(lipgloss.Top,
m.statusbar.View(ktx, renderer),
m.page.View(ktx, renderer),
)
}
func (m *Model) Update(msg tea.Msg) tea.Cmd {
m.statusbar.SetProvider(m.page)
return m.page.Update(msg)
}
func New(ktx *kontext.ProgramKtx, themesDir string, isDark bool, stsBar *statusbar.Model) *Model {
return &Model{
page: settings_page.New(ktx, themesDir, isDark),
statusbar: stsBar,
}
}
package sr_tab
import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"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_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 "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"charm.land/log/v2"
)
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,
false,
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.OpenSearch,
m,
)
m.consumptionPage = m.active
return cmd
}
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
}