Commit 90ad4ec

Ansari <ping@ansari.wtf>
2026-02-23 19:52:02
border with title component
1 parent dc75442
internal/app/component.go
@@ -0,0 +1,51 @@
+package app
+
+import (
+	"strings"
+
+	"github.com/charmbracelet/lipgloss"
+)
+
+func RenderBoxWithTitle(title, content string, styles Styles, width int) string {
+	borderColor := styles.SectionBorder.GetBorderTopForeground()
+
+	boxWidth := 0
+	if width != -1 {
+		boxWidth = width
+	}
+
+	style := lipgloss.NewStyle().
+		Border(lipgloss.RoundedBorder()).
+		BorderForeground(styles.SectionBorder.GetBorderTopForeground()).
+		Padding(1, 2)
+
+	if boxWidth > 0 {
+		style = style.Width(boxWidth)
+	}
+
+	box := style.Render(content)
+
+	lines := strings.Split(box, "\n")
+	if len(lines) == 0 {
+		return box
+	}
+
+	styledTitle := lipgloss.NewStyle().
+		Foreground(styles.SectionTitle.GetForeground()).
+		Bold(true).
+		Render(" " + title + " ")
+
+	topBorderWidth := lipgloss.Width(lines[0])
+	titleWidth := lipgloss.Width(styledTitle)
+
+	dashesAfterTitle := topBorderWidth - titleWidth - 3
+
+	if dashesAfterTitle > 0 {
+		cornerAndDash := lipgloss.NewStyle().Foreground(borderColor).Render("╭─")
+		dashes := lipgloss.NewStyle().Foreground(borderColor).Render(strings.Repeat("─", dashesAfterTitle))
+		corner := lipgloss.NewStyle().Foreground(borderColor).Render("╮")
+		lines[0] = cornerAndDash + styledTitle + dashes + corner
+	}
+
+	return strings.Join(lines, "\n")
+}
internal/app/sections.go
@@ -31,17 +31,23 @@ func (m model) renderStatusSection() string {
 		m.styles.Value.Render(strings.ToUpper(m.status.Logging)),
 	)
 
+	uptimeLine := lipgloss.JoinHorizontal(
+		lipgloss.Left,
+		m.styles.Label.Render("UpTime:"),
+		m.styles.Value.Render(m.status.UpTime),
+	)
+
 	content := lipgloss.JoinVertical(
 		lipgloss.Left,
 		statusLine,
 		loggingLine,
+		uptimeLine,
 	)
 
-	return m.styles.SectionBorder.Render(content)
+	return RenderBoxWithTitle("Firewall Status", content, m.styles, -1)
 }
 
 func (m model) renderPoliciesSection() string {
-	title := m.styles.SectionTitle.Render("Default Policies")
 
 	incomingStyle := m.getPolicyStyle(m.status.DefaultIn)
 	outgoingStyle := m.getPolicyStyle(m.status.DefaultOut)
@@ -67,28 +73,53 @@ func (m model) renderPoliciesSection() string {
 
 	content := lipgloss.JoinVertical(
 		lipgloss.Left,
-		title,
 		incomingLine,
 		outgoingLine,
 		routedLine,
 	)
-
-	return m.styles.SectionBorder.Render(content)
+	return RenderBoxWithTitle("Default Policies", content, m.styles, -1)
 }
 
-func (m model) renderRulesSection() string {
-	title := m.styles.SectionTitle.Render("Active Rules")
+func (m model) renderActiveRulesCountSection() string {
 
 	ruleCountText := fmt.Sprintf("%d rule(s) configured", len(m.status.Rules))
 	ruleCountLine := m.styles.Value.Render(ruleCountText)
 
 	content := lipgloss.JoinVertical(
 		lipgloss.Left,
-		title,
 		ruleCountLine,
 	)
 
-	return m.styles.SectionBorder.Render(content)
+	return RenderBoxWithTitle("Active Rules", content, m.styles,-1)
+}
+
+func (m model) renderAllRulesSection(width int) string {
+	title := m.styles.SectionTitle.Render("All Rules")
+	var rows []string
+
+	header := fmt.Sprintf(
+		"%-1s %-3s %-5s %-7s",
+		"NUM", "TO", "ACTION", "FROM",
+	)
+	rows = append(rows, m.styles.Label.Render(header))
+	// Rule rows
+	for i, r := range m.status.Rules {
+		if i > 1 {
+			continue
+		}
+		row := fmt.Sprintf(
+			"%-5d %-15s %-10s %-20s",
+			r.Num, r.To, r.Action, r.From,
+		)
+		rows = append(rows, m.styles.Value.Render(row))
+	}
+	table := strings.Join(rows, "\n")
+	content := lipgloss.JoinVertical(
+		lipgloss.Left,
+		title,
+		table,
+	)
+	return m.styles.SectionBorder.Width(width).Render(content)
 }
 
 func (m model) getPolicyStyle(policy string) lipgloss.Style {
@@ -104,7 +135,7 @@ func (m model) getPolicyStyle(policy string) lipgloss.Style {
 	}
 }
 
-func (m model) renderFooter() string {
+func (m model) renderFooter(width int) string {
 	keys := []string{
 		m.styles.FooterKey.Render("r") + " refresh",
 		m.styles.FooterKey.Render("?") + " help",
@@ -112,5 +143,5 @@ func (m model) renderFooter() string {
 	}
 
 	footerText := strings.Join(keys, "  •  ")
-	return m.styles.Footer.Width(m.width).Render(footerText)
+	return m.styles.Footer.Width(width).Render(footerText)
 }
internal/app/styles.go
@@ -73,7 +73,6 @@ func NewStyles() Styles {
 		Title: lipgloss.NewStyle().
 			Bold(true).
 			Foreground(mauve).
-			MarginBottom(1).
 			Align(lipgloss.Center),
 
 		Subtitle: lipgloss.NewStyle().
@@ -95,15 +94,13 @@ func NewStyles() Styles {
 
 		SectionBorder: lipgloss.NewStyle().
 			Border(lipgloss.RoundedBorder()).
-			BorderForeground(mauve).
-			Padding(1, 2).
-			MarginBottom(1).Width(30),
+			BorderForeground(mauve),
+
 
 		SectionTitle: lipgloss.NewStyle().
 			Bold(true).
 			Foreground(lavender).
-			Underline(true).
-			MarginBottom(1),
+			Underline(true),
 
 		AllowPolicy: lipgloss.NewStyle().
 			Bold(true).
@@ -127,8 +124,7 @@ func NewStyles() Styles {
 
 		Footer: lipgloss.NewStyle().
 			Foreground(overlay1).
-			MarginTop(2).
-			Padding(1).
+			PaddingBottom(1).
 			Align(lipgloss.Center),
 
 		FooterKey: lipgloss.NewStyle().
internal/app/view.go
@@ -2,51 +2,75 @@ package app
 
 import (
 	"fmt"
-	"strings"
 
 	"github.com/charmbracelet/lipgloss"
 )
 
 func (m model) View() string {
-	var b strings.Builder
+	const minWidth, minHeight = 87, 30
+	const borderWidth, borderHeight = minWidth - 5, minHeight - 3
+	if m.width < minWidth || m.height < minHeight {
+		return lipgloss.Place(
+			m.width,
+			m.height,
+			lipgloss.Center,
+			lipgloss.Center,
+			m.styles.Error.Render(fmt.Sprintf("Terminal too small!\nMinimum: %dx%d\nCurrent: %dx%d", minWidth, minHeight, m.width, m.height)),
+		)
+	}
 
-	// // If there's an error, show it prominently
 	if m.status.Error != nil {
-		errorBox := m.styles.Error.Render(fmt.Sprintf("%v", m.status.Error))
-		footer := m.renderFooter()
+		footer := m.renderFooter(borderWidth)
 		return lipgloss.JoinVertical(
 			lipgloss.Center,
 			"",
-			errorBox,
+			m.styles.Error.Render(fmt.Sprintf("%v", m.status.Error)),
 			"",
 			footer,
 		)
 	}
 
-	// Title
-	title := m.styles.Title.Width(m.width).Render("Firewall Manager")
+	borderStyle := lipgloss.NewStyle().
+		Border(lipgloss.RoundedBorder()).
+		Height(borderHeight).
+		Width(borderWidth).
+		BorderForeground(lipgloss.Color("240"))
+
+	title := m.styles.Title.
+		Width(borderWidth).
+		Render("Firewall Manager")
 
-	b.WriteString(title)
-	b.WriteString("\n\n")
+	infoSection := lipgloss.JoinHorizontal(
+		lipgloss.Top,
+		m.renderStatusSection(),
+		m.renderPoliciesSection(),	
+		)
 
-	// Status Section
-	statusSection := m.renderStatusSection()
-	b.WriteString(statusSection)
-	b.WriteString("\n")
+	content := lipgloss.JoinVertical(
+		lipgloss.Left,
+		infoSection,
+		"",
+		m.renderActiveRulesCountSection(),
+	)
 
-	// Default Policies Section
-	policiesSection := m.renderPoliciesSection()
-	b.WriteString(policiesSection)
-	b.WriteString("\n")
+	layout := lipgloss.JoinVertical(
+		lipgloss.Left,
+		title,
+		"",
+		content,
+		"",
+		m.renderFooter(borderWidth),
+	)
 
-	// Rules Count Section
-	rulesSection := m.renderRulesSection()
-	b.WriteString(rulesSection)
-	b.WriteString("\n")
+	box := borderStyle.Render(layout)
 
-	// Footer with keybindings
-	footer := m.renderFooter()
-	b.WriteString(footer)
+	box = lipgloss.Place(
+		m.width,
+		m.height,
+		lipgloss.Center,
+		lipgloss.Center,
+		box,
+	)
 
-	return b.String()
+	return box
 }
internal/ufw/main.go
@@ -7,6 +7,7 @@ import (
 	"os/exec"
 	"regexp"
 	"strings"
+	"time"
 )
 
 type UFWStatus struct {
@@ -19,6 +20,7 @@ type UFWStatus struct {
 	RawVerbose    string
 	RawNumbered   string
 	Error         error
+	UpTime        string
 }
 
 type Rule struct {
@@ -36,8 +38,8 @@ var (
 	reRuleLine = regexp.MustCompile(`^\s*\[\s*(\d+)\]\s+(.+)$`)
 )
 
-func RunSudo(args ...string) (stdout, stderr string, err error) {
-	cmd := exec.Command("sudo", append([]string{"ufw"}, args...)...)
+func RunCmd(name string, args ...string) (stdout, stderr string, err error) {
+	cmd := exec.Command(name, args...)
 	var out, errOut bytes.Buffer
 	cmd.Stdout = &out
 	cmd.Stderr = &errOut
@@ -45,6 +47,10 @@ func RunSudo(args ...string) (stdout, stderr string, err error) {
 	return out.String(), errOut.String(), err
 }
 
+func RunSudo(args ...string) (stdout, stderr string, err error) {
+	return RunCmd("sudo", append([]string{"ufw"}, args...)...)
+}
+
 func GetStatus() UFWStatus {
 	var s UFWStatus
 	// Prefer numbered for rule list; use verbose for defaults/logging.
@@ -103,6 +109,25 @@ func GetStatus() UFWStatus {
 			s.Rules = append(s.Rules, r)
 		}
 	}
+	out, _, _ := RunCmd("systemctl", "show", "ufw", "--property=ActiveEnterTimestamp")
+	line := strings.TrimSpace(out)
+	parts := strings.SplitN(line, "=", 2)
+	if len(parts) != 2 || parts[1] == "" {
+		s.Error = fmt.Errorf("could not parse timestamp")
+		return s
+	}
+	timestamp := parts[1]
+	startTime, err := time.Parse("Mon 2006-01-02 15:04:05 MST", timestamp)
+	if err != nil {
+		s.Error = err
+		return s
+	}
+
+	uptime := time.Since(startTime)
+	hours := int(uptime.Hours())
+	minutes := int(uptime.Minutes()) % 60
+	
+	s.UpTime = fmt.Sprintf("%dh %dm", hours, minutes)	
 	return s
 }
 
.gitignore
@@ -1,1 +1,2 @@
 debug.log
+/ufWall