363 lines
9.8 KiB
Go
363 lines
9.8 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/bubbles/viewport"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
pbhuman "github.com/flowy-live/admin-cli/genproto/helios/human"
|
|
pbkeypad "github.com/flowy-live/admin-cli/genproto/helios/keypad"
|
|
"github.com/mergestat/timediff"
|
|
"github.com/sirupsen/logrus"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/credentials"
|
|
"google.golang.org/grpc/metadata"
|
|
)
|
|
|
|
var (
|
|
titleStyle = lipgloss.NewStyle().
|
|
Bold(true).
|
|
Foreground(lipgloss.Color("#7D56F4")).
|
|
Padding(0, 1).
|
|
MarginBottom(1)
|
|
|
|
selectedItemStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("#FFFFFF")).
|
|
Background(lipgloss.Color("#7D56F4")).
|
|
Bold(true).
|
|
Padding(0, 1)
|
|
|
|
normalItemStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("#FAFAFA")).
|
|
Padding(0, 1)
|
|
|
|
dimItemStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("#666666")).
|
|
Padding(0, 1)
|
|
|
|
cursorStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("#FF75B5")).
|
|
Bold(true)
|
|
|
|
helpStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("#626262")).
|
|
Padding(1, 0, 0, 1)
|
|
|
|
statusStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("#7D56F4")).
|
|
Bold(true)
|
|
|
|
borderStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("#3C3C3C"))
|
|
|
|
timestampStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("#888888")).
|
|
Italic(true)
|
|
|
|
selectedTimestampStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("#CCCCCC")).
|
|
Italic(true)
|
|
|
|
detailPanelStyle = lipgloss.NewStyle().
|
|
Border(lipgloss.RoundedBorder()).
|
|
BorderForeground(lipgloss.Color("#7D56F4")).
|
|
Padding(1, 2)
|
|
|
|
detailTitleStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("#7D56F4")).
|
|
Bold(true).
|
|
Underline(true)
|
|
|
|
detailLabelStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("#888888")).
|
|
Bold(true)
|
|
|
|
detailValueStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("#FAFAFA"))
|
|
|
|
codeBlockStyle = lipgloss.NewStyle().
|
|
Background(lipgloss.Color("#1a1a1a")).
|
|
Foreground(lipgloss.Color("#00FF00")).
|
|
Padding(1, 1).
|
|
MarginTop(1)
|
|
)
|
|
|
|
type HumanData struct {
|
|
Email string
|
|
DisplayName string
|
|
JoinedAt time.Time
|
|
KeypadConfiguration string
|
|
}
|
|
|
|
type model struct {
|
|
humans []*HumanData
|
|
cursor int
|
|
selectedHumanEmail string
|
|
viewport viewport.Model
|
|
detailViewport viewport.Model
|
|
ready bool
|
|
showKeypad bool
|
|
terminalWidth int
|
|
terminalHeight int
|
|
focusOnDetail bool // true = detail panel has focus, false = list has focus
|
|
}
|
|
|
|
func initialModel(humans []*HumanData) model {
|
|
return model{
|
|
humans: humans,
|
|
cursor: 0,
|
|
showKeypad: false,
|
|
}
|
|
}
|
|
|
|
func (m model) Init() tea.Cmd {
|
|
// Just return `nil`, which means "no I/O right now, please."
|
|
return nil
|
|
}
|
|
|
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.terminalWidth = msg.Width
|
|
m.terminalHeight = msg.Height
|
|
|
|
headerHeight := 3 // title + status + separator
|
|
footerHeight := 2 // help text
|
|
verticalMarginHeight := headerHeight + footerHeight
|
|
|
|
// Split width: 50% for list, 50% for details
|
|
listWidth := msg.Width / 2
|
|
detailWidth := msg.Width - listWidth - 3 // -3 for padding/border
|
|
|
|
if !m.ready {
|
|
m.viewport = viewport.New(listWidth, msg.Height-verticalMarginHeight)
|
|
m.viewport.YPosition = headerHeight
|
|
m.viewport.SetContent(m.renderList())
|
|
|
|
m.detailViewport = viewport.New(detailWidth, msg.Height-verticalMarginHeight)
|
|
m.detailViewport.YPosition = headerHeight
|
|
m.detailViewport.SetContent(m.renderDetails())
|
|
|
|
m.ready = true
|
|
} else {
|
|
m.viewport.Width = listWidth
|
|
m.viewport.Height = msg.Height - verticalMarginHeight
|
|
|
|
m.detailViewport.Width = detailWidth
|
|
m.detailViewport.Height = msg.Height - verticalMarginHeight
|
|
}
|
|
|
|
case tea.KeyMsg:
|
|
switch msg.String() {
|
|
case "ctrl+c", "q":
|
|
return m, tea.Quit
|
|
|
|
case "ctrl+u":
|
|
// Scroll detail panel up only
|
|
m.detailViewport.LineUp(3)
|
|
return m, nil
|
|
|
|
case "ctrl+d":
|
|
// Scroll detail panel down only
|
|
m.detailViewport.LineDown(3)
|
|
return m, nil
|
|
|
|
case "up", "k":
|
|
if m.cursor > 0 {
|
|
m.cursor--
|
|
m.viewport.SetContent(m.renderList())
|
|
m.detailViewport.SetContent(m.renderDetails())
|
|
m.detailViewport.GotoTop() // Reset detail scroll position
|
|
|
|
// Auto-scroll left viewport to keep cursor visible
|
|
if m.cursor < m.viewport.YOffset {
|
|
m.viewport.YOffset = m.cursor
|
|
}
|
|
}
|
|
|
|
case "down", "j":
|
|
if m.cursor < len(m.humans)-1 {
|
|
m.cursor++
|
|
m.viewport.SetContent(m.renderList())
|
|
m.detailViewport.SetContent(m.renderDetails())
|
|
m.detailViewport.GotoTop() // Reset detail scroll position
|
|
|
|
// Auto-scroll left viewport to keep cursor visible
|
|
if m.cursor >= m.viewport.YOffset+m.viewport.Height {
|
|
m.viewport.YOffset = m.cursor - m.viewport.Height + 1
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m model) renderList() string {
|
|
var b strings.Builder
|
|
for i, human := range m.humans {
|
|
email := human.Email
|
|
|
|
// Get relative time ago
|
|
timeAgo := timediff.TimeDiff(human.JoinedAt)
|
|
|
|
if m.cursor == i {
|
|
// Current selection - highlighted
|
|
cursor := cursorStyle.Render("▸")
|
|
item := selectedItemStyle.Render(email)
|
|
timestamp := selectedTimestampStyle.Render(fmt.Sprintf("(%s)", timeAgo))
|
|
b.WriteString(fmt.Sprintf("%s %s %s\n", cursor, item, timestamp))
|
|
} else {
|
|
// Normal item
|
|
cursor := dimItemStyle.Render(" ")
|
|
item := normalItemStyle.Render(email)
|
|
timestamp := timestampStyle.Render(fmt.Sprintf("(%s)", timeAgo))
|
|
b.WriteString(fmt.Sprintf("%s %s %s\n", cursor, item, timestamp))
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func (m model) renderDetails() string {
|
|
if len(m.humans) == 0 || m.cursor >= len(m.humans) {
|
|
return ""
|
|
}
|
|
|
|
human := m.humans[m.cursor]
|
|
var b strings.Builder
|
|
|
|
// Title
|
|
b.WriteString(detailTitleStyle.Render("Human Details") + "\n\n")
|
|
|
|
// Email
|
|
b.WriteString(detailLabelStyle.Render("Email: "))
|
|
b.WriteString(detailValueStyle.Render(human.Email) + "\n\n")
|
|
|
|
// Display Name
|
|
if human.DisplayName != "" {
|
|
b.WriteString(detailLabelStyle.Render("Display Name: "))
|
|
b.WriteString(detailValueStyle.Render(human.DisplayName) + "\n\n")
|
|
}
|
|
|
|
// Joined At
|
|
b.WriteString(detailLabelStyle.Render("Joined: "))
|
|
timeAgo := timediff.TimeDiff(human.JoinedAt)
|
|
joinedFormatted := human.JoinedAt.Format("Jan 2, 2006 at 3:04 PM")
|
|
b.WriteString(detailValueStyle.Render(fmt.Sprintf("%s (%s)", joinedFormatted, timeAgo)) + "\n\n")
|
|
|
|
// Keypad Configuration
|
|
if human.KeypadConfiguration != "" {
|
|
b.WriteString(detailLabelStyle.Render("Keypad Configuration:") + "\n")
|
|
b.WriteString(codeBlockStyle.Render(human.KeypadConfiguration))
|
|
} else {
|
|
b.WriteString(detailLabelStyle.Render("Keypad Configuration: "))
|
|
b.WriteString(dimItemStyle.Render("(none)"))
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
func (m model) View() string {
|
|
if !m.ready {
|
|
return "\n Initializing..."
|
|
}
|
|
|
|
// Header with main data
|
|
sevenDaysAgo := time.Now().AddDate(0, 0, -7)
|
|
pastSevenDaysSignupsCount := 0
|
|
for _, human := range m.humans {
|
|
if human.JoinedAt.After(sevenDaysAgo) {
|
|
pastSevenDaysSignupsCount++
|
|
}
|
|
}
|
|
header := titleStyle.Render(fmt.Sprintf("👥 Humans | %d total | %d in the past 7 days", len(m.humans), pastSevenDaysSignupsCount))
|
|
|
|
// Status bar showing current position
|
|
statusBar := statusStyle.Render(fmt.Sprintf(" %d/%d", m.cursor+1, len(m.humans)))
|
|
|
|
// Help text
|
|
help := helpStyle.Render("↑/k up • ↓/j down • ctrl+u/d scroll detail • q quit")
|
|
|
|
// Create two-column layout
|
|
leftPanel := m.viewport.View()
|
|
rightPanel := m.detailViewport.View()
|
|
|
|
// Combine panels side by side
|
|
mainContent := lipgloss.JoinHorizontal(
|
|
lipgloss.Top,
|
|
leftPanel,
|
|
" ", // spacing
|
|
rightPanel,
|
|
)
|
|
|
|
return fmt.Sprintf("%s%s\n%s\n%s", header, statusBar, mainContent, help)
|
|
}
|
|
|
|
func unaryInterceptor(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
|
|
md := metadata.Pairs("authorization", "talksik")
|
|
ctx = metadata.NewOutgoingContext(ctx, md)
|
|
return invoker(ctx, method, req, reply, cc, opts...)
|
|
}
|
|
|
|
func main() {
|
|
logrus.Infof("hello world")
|
|
|
|
heliosAddr := "helios.flowy.live:443"
|
|
creds := credentials.NewTLS(&tls.Config{
|
|
InsecureSkipVerify: true,
|
|
})
|
|
heliosService, err := grpc.NewClient(heliosAddr, grpc.WithTransportCredentials(creds), grpc.WithUnaryInterceptor(unaryInterceptor))
|
|
if err != nil {
|
|
logrus.Fatalf("connection to aero server invalid: %v", err)
|
|
}
|
|
defer heliosService.Close()
|
|
|
|
humanService := pbhuman.NewHumanServiceClient(heliosService)
|
|
keypadService := pbkeypad.NewKeypadServiceClient(heliosService)
|
|
humansResponse, err := humanService.ListHumans(context.Background(), &pbhuman.ListHumansRequest{})
|
|
if err != nil {
|
|
logrus.Fatalf("unable to fetch humans: %v", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
keypadsResponse, err := keypadService.ListKeypads(context.Background(), &pbkeypad.ListKeypadsRequest{})
|
|
if err != nil {
|
|
logrus.Fatalf("unable to fetch keypads: %v", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
keypadsResponse.GetHumanKeypads()[0].GetHumanId()
|
|
keypadsResponse.GetHumanKeypads()[0].GetYamlConfig()
|
|
|
|
logrus.Info("connected to helios")
|
|
logrus.Infof("fetched %d humans", len(humansResponse.GetHumans()))
|
|
|
|
humansData := []*HumanData{}
|
|
for _, human := range humansResponse.GetHumans() {
|
|
keypadConfiguration := ""
|
|
for _, keypad := range keypadsResponse.GetHumanKeypads() {
|
|
if keypad.GetHumanId() == human.GetId() {
|
|
keypadConfiguration = keypad.GetYamlConfig()
|
|
}
|
|
}
|
|
humansData = append(humansData, &HumanData{
|
|
Email: human.GetEmail(),
|
|
DisplayName: human.GetDisplayName(),
|
|
JoinedAt: human.GetJoinedAt().AsTime(),
|
|
KeypadConfiguration: keypadConfiguration,
|
|
})
|
|
}
|
|
|
|
p := tea.NewProgram(initialModel(humansData))
|
|
if _, err := p.Run(); err != nil {
|
|
fmt.Printf("Alas, there's been an error: %v", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|