Commit 90ad4ec
Changed files (6)
internal
ufw
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