├── docs ├── .gitignore ├── .vitepress │ ├── cache │ │ └── deps │ │ │ ├── package.json │ │ │ ├── vue.js.map │ │ │ ├── vitepress___@vueuse_core.js.map │ │ │ ├── _metadata.json │ │ │ └── vue.js │ ├── config.mts │ └── theme │ │ ├── index.ts │ │ ├── Layout.vue │ │ └── style.css ├── public │ ├── 1.png │ ├── 2.png │ ├── 3.png │ ├── 4.png │ ├── 5.png │ ├── 6.png │ ├── 7.png │ ├── 8.png │ ├── 9.png │ ├── 10.png │ └── 11.png ├── docs │ ├── config │ │ ├── general.md │ │ ├── index.md │ │ └── styles.md │ ├── index.md │ ├── contributing.md │ └── install.md ├── package.json ├── index.ts ├── index.md └── about │ └── index.md ├── .gitignore ├── main.go ├── internal ├── styles │ ├── borders.go │ ├── styles.go │ └── text.go ├── docker │ ├── docker.go │ ├── volumes.go │ ├── networks.go │ ├── images.go │ ├── daemon.go │ ├── events.go │ ├── vulnerability.go │ └── containers.go ├── config │ ├── configModels.go │ ├── config.go │ ├── styleModels.go │ ├── keyModels.go │ └── default.go ├── enums │ └── enums.go ├── data │ ├── mem.go │ ├── disk.go │ └── cpu.go ├── models │ ├── fzf │ │ ├── fzfti.go │ │ ├── fzfvp.go │ │ └── fzf.go │ ├── msg │ │ └── msg.go │ ├── error │ │ └── error.go │ ├── home │ │ ├── daemon.go │ │ ├── stats.go │ │ ├── home.go │ │ ├── logs.go │ │ └── sys.go │ ├── help │ │ └── help.go │ ├── vulnerability │ │ ├── scan.go │ │ ├── vulnerability.go │ │ └── list.go │ ├── volumes │ │ ├── volumes.go │ │ ├── list.go │ │ └── details.go │ ├── networks │ │ ├── networks.go │ │ ├── list.go │ │ └── detail.go │ ├── nav │ │ └── nav.go │ ├── containers │ │ └── list.go │ ├── images │ │ ├── list.go │ │ └── images.go │ └── monitoring │ │ └── monitoring.go ├── keymap │ ├── keymap.go │ ├── monitoring.go │ ├── vuln.go │ ├── fzf.go │ ├── net.go │ ├── vol.go │ ├── images.go │ ├── navmap.go │ └── containers.go ├── colors │ └── colors.go ├── messages │ └── messages.go └── utils │ └── utils.go ├── MAINTAINERS.md ├── cmd ├── run.go ├── root.go └── dump.go ├── SECURITY.md ├── dump.toml ├── CONTRIBUTING.md ├── CODE_OF_CONDUCT.md ├── .goreleaser.yaml ├── ROADMAP.md ├── go.mod └── README.md /docs/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | cruise 2 | # Added by goreleaser init: 3 | dist/ 4 | -------------------------------------------------------------------------------- /docs/.vitepress/cache/deps/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module" 3 | } 4 | -------------------------------------------------------------------------------- /docs/public/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cruise-org/cruise/HEAD/docs/public/1.png -------------------------------------------------------------------------------- /docs/public/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cruise-org/cruise/HEAD/docs/public/2.png -------------------------------------------------------------------------------- /docs/public/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cruise-org/cruise/HEAD/docs/public/3.png -------------------------------------------------------------------------------- /docs/public/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cruise-org/cruise/HEAD/docs/public/4.png -------------------------------------------------------------------------------- /docs/public/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cruise-org/cruise/HEAD/docs/public/5.png -------------------------------------------------------------------------------- /docs/public/6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cruise-org/cruise/HEAD/docs/public/6.png -------------------------------------------------------------------------------- /docs/public/7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cruise-org/cruise/HEAD/docs/public/7.png -------------------------------------------------------------------------------- /docs/public/8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cruise-org/cruise/HEAD/docs/public/8.png -------------------------------------------------------------------------------- /docs/public/9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cruise-org/cruise/HEAD/docs/public/9.png -------------------------------------------------------------------------------- /docs/.vitepress/config.mts: -------------------------------------------------------------------------------- 1 | import cfg from "../index.ts"; 2 | 3 | export default cfg 4 | -------------------------------------------------------------------------------- /docs/public/10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cruise-org/cruise/HEAD/docs/public/10.png -------------------------------------------------------------------------------- /docs/public/11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cruise-org/cruise/HEAD/docs/public/11.png -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/NucleoFusion/cruise/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /docs/.vitepress/cache/deps/vue.js.map: -------------------------------------------------------------------------------- 1 | { 2 | "version": 3, 3 | "sources": [], 4 | "sourcesContent": [], 5 | "mappings": "", 6 | "names": [] 7 | } 8 | -------------------------------------------------------------------------------- /docs/.vitepress/cache/deps/vitepress___@vueuse_core.js.map: -------------------------------------------------------------------------------- 1 | { 2 | "version": 3, 3 | "sources": [], 4 | "sourcesContent": [], 5 | "mappings": "", 6 | "names": [] 7 | } 8 | -------------------------------------------------------------------------------- /internal/styles/borders.go: -------------------------------------------------------------------------------- 1 | package styles 2 | 3 | import "github.com/charmbracelet/lipgloss" 4 | 5 | func DropdownBorder() lipgloss.Border { 6 | b := lipgloss.RoundedBorder() 7 | 8 | b.Top = " " 9 | b.TopLeft = b.Left // "|" 10 | b.TopRight = b.Right // "|" 11 | 12 | return b 13 | } 14 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.ts: -------------------------------------------------------------------------------- 1 | import { h } from 'vue' 2 | import type { Theme } from 'vitepress' 3 | import DefaultTheme from 'vitepress/theme' 4 | import Layout from "./Layout.vue" 5 | import './style.css' 6 | 7 | export default { 8 | extends: DefaultTheme, 9 | Layout: Layout, 10 | } satisfies Theme 11 | 12 | -------------------------------------------------------------------------------- /internal/docker/docker.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/docker/docker/client" 7 | ) 8 | 9 | var cli *client.Client 10 | 11 | func init() { 12 | c, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 13 | if err != nil { 14 | log.Fatal("Docker Error: " + err.Error()) 15 | } 16 | 17 | cli = c 18 | } 19 | -------------------------------------------------------------------------------- /docs/docs/config/general.md: -------------------------------------------------------------------------------- 1 | # General 2 | 3 | It describes about the global configuration of Cruise. 4 | 5 | ## Export Directory 6 | Exports directory path where exported files and data is saved. Can be set using `export_dir` key. 7 | 8 | ## Term 9 | It is the default terminal to use for opening an `exec -it` instance. Can be set using `term` key. 10 | **HIGHLY RECOMMENDED that you set this on fresh install** 11 | -------------------------------------------------------------------------------- /internal/config/configModels.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | type Config struct { 4 | Global Global `mapstructure:"global" toml:"global"` 5 | Styles Styles `mapstructure:"styles" toml:"styles"` 6 | Keybinds Keybinds `mapstructure:"keybinds" toml:"keybinds"` 7 | } 8 | 9 | type Global struct { 10 | ExportDir string `mapstructure:"export_dir" toml:"export_dir"` 11 | Term string `mapstructure:"term" toml:"term"` // TODO 12 | } 13 | -------------------------------------------------------------------------------- /internal/enums/enums.go: -------------------------------------------------------------------------------- 1 | package enums 2 | 3 | type PageType int 4 | 5 | const ( 6 | Home PageType = iota 7 | Containers 8 | Images 9 | Vulnerability 10 | Monitoring 11 | Networks 12 | Volumes 13 | ) 14 | 15 | type ErrorType int 16 | 17 | const ( 18 | Fatal ErrorType = iota 19 | Warning 20 | ) 21 | 22 | type Severity int 23 | 24 | const ( 25 | Critical Severity = iota 26 | High 27 | Medium 28 | Low 29 | Unknown 30 | ) 31 | -------------------------------------------------------------------------------- /MAINTAINERS.md: -------------------------------------------------------------------------------- 1 | # MAINTAINERS 2 | 3 | Active maintainers for Cruise. 4 | Maintainers review PRs, respond to issues, and guide project direction in their areas. 5 | 6 | | GitHub Username | Area of Responsibility | 7 | |------------------|--------------------------------------| 8 | | NucleoFusion | Core, Features, Docs, Roadmap, General | 9 | | GreatNateDev | NixOS Installation/Packaging | 10 | 11 | _Last updated: 2025-11-19_ -------------------------------------------------------------------------------- /internal/data/mem.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import "github.com/shirou/gopsutil/v3/mem" 4 | 5 | type MemInfo struct { 6 | Total float64 7 | Used float64 8 | Usage float64 9 | Err error 10 | } 11 | 12 | func GetMemInfo() *MemInfo { 13 | v, err := mem.VirtualMemory() 14 | 15 | totalGB := float64(v.Total) / (1 << 30) 16 | usedGB := float64(v.Used) / (1 << 30) 17 | usedPercent := v.UsedPercent 18 | 19 | return &MemInfo{ 20 | Total: totalGB, 21 | Used: usedGB, 22 | Usage: usedPercent, 23 | Err: err, 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /internal/models/fzf/fzfti.go: -------------------------------------------------------------------------------- 1 | package fzf 2 | 3 | import ( 4 | "github.com/NucleoFusion/cruise/internal/colors" 5 | "github.com/charmbracelet/bubbles/textinput" 6 | "github.com/charmbracelet/lipgloss" 7 | ) 8 | 9 | func TextStyle() lipgloss.Style { 10 | return lipgloss.NewStyle().Foreground(colors.Load().Text) 11 | } 12 | 13 | func NewTI(w int) textinput.Model { 14 | ti := textinput.New() 15 | ti.Placeholder = "Search..." 16 | ti.Prompt = "Filter : " 17 | ti.Width = w - 12 18 | ti.TextStyle = TextStyle() 19 | ti.PromptStyle = TextStyle() 20 | ti.Focus() 21 | 22 | return ti 23 | } 24 | -------------------------------------------------------------------------------- /cmd/run.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/NucleoFusion/cruise/internal/models/root" 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | var runCmd = &cobra.Command{ 12 | Use: "run", 13 | Short: "Run the Toney TUI", 14 | Run: func(cmd *cobra.Command, args []string) { 15 | p := tea.NewProgram(root.NewRoot(), tea.WithAltScreen()) 16 | if _, err := p.Run(); err != nil { 17 | fmt.Println("Alas, error") 18 | fmt.Println(err.Error()) 19 | } 20 | }, 21 | } 22 | 23 | func init() { 24 | rootCmd.AddCommand(runCmd) 25 | } 26 | -------------------------------------------------------------------------------- /internal/data/disk.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "github.com/shirou/gopsutil/v3/disk" 5 | ) 6 | 7 | type DiskInfo struct { 8 | Total float64 9 | Used float64 10 | Usage float64 11 | Err error 12 | } 13 | 14 | func GetDiskInfo() *DiskInfo { 15 | usage, err := disk.Usage("/") 16 | 17 | // Convert bytes to GB with 1 decimal point 18 | totalGB := float64(usage.Total) / (1 << 30) 19 | usedGB := float64(usage.Used) / (1 << 30) 20 | usedPercent := usage.UsedPercent 21 | 22 | return &DiskInfo{ 23 | Total: totalGB, 24 | Used: usedGB, 25 | Usage: usedPercent, 26 | Err: err, 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1", 7 | "docs:dev": "vitepress dev", 8 | "docs:build": "vitepress build", 9 | "docs:preview": "vitepress preview", 10 | "docs:deploy": "npm run docs:build && gh-pages -d app/docs/.vitepress/dist" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "ISC", 15 | "description": "", 16 | "dependencies": { 17 | "vitepress": "^1.6.4", 18 | "vue": "^3.5.18" 19 | }, 20 | "devDependencies": { 21 | "gh-pages": "^6.3.0" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/NucleoFusion/cruise/internal/config" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | var rootCmd = &cobra.Command{ 12 | Use: "cruise", 13 | Short: "Cruise is a TUI for Docker", 14 | Long: `Cruise is a powerful terminal UI for managing your docker containers, composes and much more.`, 15 | Run: func(cmd *cobra.Command, args []string) { 16 | runCmd.Run(cmd, args) 17 | }, 18 | } 19 | 20 | func Execute() { 21 | if err := config.SetCfg(); err != nil { 22 | fmt.Println(err) 23 | os.Exit(1) 24 | } 25 | 26 | if err := rootCmd.Execute(); err != nil { 27 | fmt.Println(err) 28 | os.Exit(1) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /internal/keymap/keymap.go: -------------------------------------------------------------------------------- 1 | package keymap 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/NucleoFusion/cruise/internal/config" 7 | "github.com/charmbracelet/bubbles/key" 8 | ) 9 | 10 | type DynamicMap struct { 11 | keys []key.Binding 12 | } 13 | 14 | func QuickQuitKey() key.Binding { 15 | q := config.Cfg.Keybinds.Global.QuickQuit 16 | return key.NewBinding(key.WithKeys(q), 17 | key.WithHelp(fmt.Sprintf(" %s ", q), "quit")) 18 | } 19 | 20 | func NewDynamic(keys []key.Binding) *DynamicMap { 21 | return &DynamicMap{ 22 | keys: keys, 23 | } 24 | } 25 | 26 | func (d DynamicMap) ShortHelp() []key.Binding { 27 | return d.keys 28 | } 29 | 30 | func (d DynamicMap) FullHelp() [][]key.Binding { 31 | return [][]key.Binding{d.keys} 32 | } 33 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/Layout.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 16 | 17 | 33 | 34 | -------------------------------------------------------------------------------- /docs/docs/config/index.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | Cruise supports a flexible configuration file for customizing themes, keybinds etc. We use a TOML format and the default config file is at: 4 | 5 | 6 | - Linux/MacOS: 7 | ``` 8 | ~/.config/cruise/config.toml 9 | ``` 10 | 11 | - Windows: 12 | ``` 13 | C:\Users\\.cruise\config.toml 14 | ``` 15 | 16 | ## File Format 17 | 18 | Cruise uses TOML for its configuration file. Making it readable and easy to edit. 19 | 20 | An example minimal file with vim motions (hjkl motions) 21 | ```toml 22 | [keybinds.global] 23 | up = "k" 24 | down = "j" 25 | ``` 26 | 27 | ## Key Sections 28 | 29 | the config file has 3 main sections: 30 | 31 | - **General**: for general stuff such as shell, export_dir etc 32 | - **Styles**: for customizing styles and colors 33 | - **Keybinds**: for customizing keybinds 34 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | Thank you for helping keep Cruise secure! 4 | 5 | ## Reporting Vulnerabilities 6 | 7 | If you discover a security issue, please report it by contacting the mentors or by opening a private GitHub Security Advisory. When reporting, please include: 8 | 9 | - A description of the vulnerability 10 | - Steps to reproduce (if possible) 11 | - Impact and potential risk 12 | 13 | We aim to respond within 5 business days and will work with you to investigate, address, and disclose the issue responsibly. 14 | 15 | Please do **not** file security issues publicly (as GitHub Issues or Discussions) to prevent unnecessary risk to users. 16 | 17 | Your report will be kept confidential until a patch is released. 18 | 19 | ## Public Disclosure 20 | 21 | After a fix is implemented, we will announce the resolution in the release notes. 22 | 23 | Thank you for contributing to a safer open source ecosystem! -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/NucleoFusion/cruise/internal/utils" 9 | "github.com/spf13/viper" 10 | ) 11 | 12 | var Cfg Config 13 | 14 | func SetCfg() error { 15 | cfg := utils.GetCfgDir() 16 | p := filepath.Join(cfg, "config.toml") 17 | 18 | if _, err := os.Stat(p); os.IsNotExist(err) { 19 | if err := os.MkdirAll(cfg, 0o755); err != nil { 20 | fmt.Printf("failed to create config dir: %s", err.Error()) 21 | os.Exit(1) 22 | } 23 | 24 | if _, err := os.Create(p); err != nil { 25 | fmt.Printf("failed to create config file: %s", err.Error()) 26 | os.Exit(1) 27 | } 28 | } 29 | 30 | viper.SetConfigFile(p) 31 | 32 | if err := viper.ReadInConfig(); err != nil { 33 | return err 34 | } 35 | 36 | Cfg = Default() 37 | 38 | if err := viper.Unmarshal(&Cfg); err != nil { 39 | return err 40 | } 41 | 42 | return nil 43 | } 44 | -------------------------------------------------------------------------------- /internal/data/cpu.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/shirou/gopsutil/v3/cpu" 7 | ) 8 | 9 | type CPUInfo struct { 10 | Usage float64 11 | LogicCores int 12 | PhysicalCores int 13 | Mhz float64 14 | Error error 15 | } 16 | 17 | func GetCPUInfo() *CPUInfo { 18 | c, err := cpu.Info() 19 | if err != nil { 20 | return &CPUInfo{Error: err} 21 | } 22 | cp := c[0] 23 | 24 | usage, err := cpu.Percent(time.Second, false) // Overall usage 25 | if err != nil { 26 | return &CPUInfo{Error: err} 27 | } 28 | 29 | logical, err := cpu.Counts(true) 30 | if err != nil { 31 | return &CPUInfo{Error: err} 32 | } 33 | 34 | physical, err := cpu.Counts(true) 35 | if err != nil { 36 | return &CPUInfo{Error: err} 37 | } 38 | 39 | return &CPUInfo{ 40 | Usage: usage[0], 41 | LogicCores: logical, 42 | PhysicalCores: physical, 43 | Mhz: cp.Mhz, 44 | Error: nil, 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /internal/models/fzf/fzfvp.go: -------------------------------------------------------------------------------- 1 | package fzf 2 | 3 | import ( 4 | "github.com/NucleoFusion/cruise/internal/colors" 5 | "github.com/charmbracelet/bubbles/viewport" 6 | "github.com/charmbracelet/lipgloss" 7 | ) 8 | 9 | func ItemLineStyle(w int) lipgloss.Style { 10 | return lipgloss.NewStyle().Width(w).Foreground(colors.Load().Text) 11 | } 12 | 13 | func SelectedItemStyle(w int) lipgloss.Style { 14 | return ItemLineStyle(w).Background(colors.Load().MenuSelectedBg).Foreground(colors.Load().MenuSelectedText) 15 | } 16 | 17 | func VPStyle() lipgloss.Style { 18 | return lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(colors.Load().FocusedBorder). 19 | Foreground(colors.Load().Text) 20 | } 21 | 22 | func NewVP(w int, h int, items []string) viewport.Model { 23 | vp := viewport.New(w/3, h/2) 24 | vp.Style = VPStyle() 25 | 26 | text := "" 27 | for k, v := range items { 28 | if k == 0 { 29 | text += SelectedItemStyle(w/3).Render(v) + "\n" 30 | continue 31 | } 32 | 33 | text += ItemLineStyle(w/3).Render(v) + "\n" 34 | } 35 | 36 | vp.SetContent(text) 37 | 38 | return vp 39 | } 40 | -------------------------------------------------------------------------------- /internal/keymap/monitoring.go: -------------------------------------------------------------------------------- 1 | package keymap 2 | 3 | import ( 4 | "reflect" 5 | 6 | "github.com/NucleoFusion/cruise/internal/config" 7 | "github.com/charmbracelet/bubbles/key" 8 | ) 9 | 10 | type MonitorMap struct { 11 | Search key.Binding 12 | ExitSearch key.Binding 13 | Export key.Binding 14 | } 15 | 16 | func NewMonitorMap() MonitorMap { 17 | m := config.Cfg.Keybinds.Monitoring 18 | return MonitorMap{ 19 | Search: key.NewBinding( 20 | key.WithKeys(m.Search), 21 | key.WithHelp(m.Search, "search"), 22 | ), 23 | ExitSearch: key.NewBinding( 24 | key.WithKeys(m.ExitSearch), 25 | key.WithHelp(m.ExitSearch, "exit search"), 26 | ), 27 | Export: key.NewBinding( 28 | key.WithKeys(m.Export), 29 | key.WithHelp(m.Export, "export"), 30 | ), 31 | } 32 | } 33 | 34 | func (m MonitorMap) Bindings() []key.Binding { 35 | var bindings []key.Binding 36 | 37 | v := reflect.ValueOf(m) 38 | for i := 0; i < v.NumField(); i++ { 39 | field := v.Field(i) 40 | if binding, ok := field.Interface().(key.Binding); ok { 41 | bindings = append(bindings, binding) 42 | } 43 | } 44 | 45 | return bindings 46 | } 47 | -------------------------------------------------------------------------------- /internal/keymap/vuln.go: -------------------------------------------------------------------------------- 1 | package keymap 2 | 3 | import ( 4 | "reflect" 5 | 6 | "github.com/NucleoFusion/cruise/internal/config" 7 | "github.com/charmbracelet/bubbles/key" 8 | ) 9 | 10 | type VulnMap struct { 11 | FocusScanners key.Binding 12 | FocusList key.Binding 13 | Export key.Binding 14 | } 15 | 16 | func NewVulnMap() VulnMap { 17 | m := config.Cfg.Keybinds.Vulnerability 18 | return VulnMap{ 19 | FocusScanners: key.NewBinding( 20 | key.WithKeys(m.FocusScanners), 21 | key.WithHelp(m.FocusScanners, "focus scanners"), 22 | ), 23 | FocusList: key.NewBinding( 24 | key.WithKeys(m.FocusList), 25 | key.WithHelp(m.FocusList, "focus list"), 26 | ), 27 | Export: key.NewBinding( 28 | key.WithKeys(m.Export), 29 | key.WithHelp(m.Export, "export"), 30 | ), 31 | } 32 | } 33 | 34 | func (m VulnMap) Bindings() []key.Binding { 35 | var bindings []key.Binding 36 | 37 | v := reflect.ValueOf(m) 38 | for i := 0; i < v.NumField(); i++ { 39 | field := v.Field(i) 40 | if binding, ok := field.Interface().(key.Binding); ok { 41 | bindings = append(bindings, binding) 42 | } 43 | } 44 | 45 | return bindings 46 | } 47 | -------------------------------------------------------------------------------- /internal/keymap/fzf.go: -------------------------------------------------------------------------------- 1 | package keymap 2 | 3 | import ( 4 | "reflect" 5 | 6 | "github.com/NucleoFusion/cruise/internal/config" 7 | "github.com/charmbracelet/bubbles/key" 8 | ) 9 | 10 | type FuzzyMap struct { 11 | StartWriting key.Binding 12 | Up key.Binding 13 | Down key.Binding 14 | Enter key.Binding 15 | Exit key.Binding 16 | } 17 | 18 | func NewFuzzyMap() FuzzyMap { 19 | m := config.Cfg.Keybinds.Fzf 20 | return FuzzyMap{ 21 | Up: key.NewBinding( 22 | key.WithKeys(m.Up), 23 | key.WithHelp(m.Up, "up"), 24 | ), 25 | Down: key.NewBinding( 26 | key.WithKeys(m.Down), 27 | key.WithHelp(m.Down, "down"), 28 | ), 29 | Enter: key.NewBinding( 30 | key.WithKeys(m.Enter), 31 | key.WithHelp(m.Enter, "enter"), 32 | ), 33 | Exit: key.NewBinding( 34 | key.WithKeys(m.Exit), 35 | key.WithHelp(m.Exit, "exit"), 36 | ), 37 | } 38 | } 39 | 40 | func (m FuzzyMap) Bindings() []key.Binding { 41 | var bindings []key.Binding 42 | 43 | v := reflect.ValueOf(m) 44 | for i := 0; i < v.NumField(); i++ { 45 | field := v.Field(i) 46 | if binding, ok := field.Interface().(key.Binding); ok { 47 | bindings = append(bindings, binding) 48 | } 49 | } 50 | 51 | return bindings 52 | } 53 | -------------------------------------------------------------------------------- /internal/config/styleModels.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | type Styles struct { 4 | Text string `mapstructure:"text" toml:"text"` 5 | SubtitleText string `mapstructure:"subtitle_text" toml:"subtitle_text"` 6 | SubtitleBg string `mapstructure:"subtitle_bg" toml:"subtitle_bg"` 7 | UnfocusedBorder string `mapstructure:"unfocused_border" toml:"unfocused_border"` 8 | FocusedBorder string `mapstructure:"focused_border" toml:"focused_border"` 9 | HelpKeyBg string `mapstructure:"help_key_bg" toml:"help_key_bg"` 10 | HelpKeyText string `mapstructure:"help_key_text" toml:"help_key_text"` 11 | HelpDescText string `mapstructure:"help_desc_text" toml:"help_desc_text"` 12 | MenuSelectedBg string `mapstructure:"menu_selected_bg" toml:"menu_selected_bg"` 13 | MenuSelectedText string `mapstructure:"menu_selected_text" toml:"menu_selected_text"` 14 | ErrorText string `mapstructure:"error_text" toml:"error_text"` 15 | ErrorBg string `mapstructure:"error_bg" toml:"error_bg"` 16 | PopupBorder string `mapstructure:"popup_border" toml:"popup_border"` 17 | PlaceholderText string `mapstructure:"placeholder_text" toml:"placeholder_text"` 18 | MsgText string `mapstructure:"msg_text" toml:"msg_text"` 19 | } 20 | -------------------------------------------------------------------------------- /internal/keymap/net.go: -------------------------------------------------------------------------------- 1 | package keymap 2 | 3 | import ( 4 | "reflect" 5 | 6 | "github.com/NucleoFusion/cruise/internal/config" 7 | "github.com/charmbracelet/bubbles/key" 8 | ) 9 | 10 | type NetMap struct { 11 | Remove key.Binding 12 | Prune key.Binding 13 | ShowDetails key.Binding 14 | ExitDetails key.Binding 15 | } 16 | 17 | func NewNetMap() NetMap { 18 | m := config.Cfg.Keybinds.Network 19 | return NetMap{ 20 | Remove: key.NewBinding( 21 | key.WithKeys(m.Remove), 22 | key.WithHelp(m.Remove, "remove"), 23 | ), 24 | Prune: key.NewBinding( 25 | key.WithKeys(m.Prune), 26 | key.WithHelp(m.Prune, "prune"), 27 | ), 28 | ShowDetails: key.NewBinding( 29 | key.WithKeys(m.ShowDetails), 30 | key.WithHelp(m.ShowDetails, "show details"), 31 | ), 32 | ExitDetails: key.NewBinding( 33 | key.WithKeys(m.ExitDetails), 34 | key.WithHelp(m.ExitDetails, "exit details"), 35 | ), 36 | } 37 | } 38 | 39 | func (m NetMap) Bindings() []key.Binding { 40 | var bindings []key.Binding 41 | 42 | v := reflect.ValueOf(m) 43 | for i := 0; i < v.NumField(); i++ { 44 | field := v.Field(i) 45 | if binding, ok := field.Interface().(key.Binding); ok { 46 | bindings = append(bindings, binding) 47 | } 48 | } 49 | 50 | return bindings 51 | } 52 | -------------------------------------------------------------------------------- /internal/keymap/vol.go: -------------------------------------------------------------------------------- 1 | package keymap 2 | 3 | import ( 4 | "reflect" 5 | 6 | "github.com/NucleoFusion/cruise/internal/config" 7 | "github.com/charmbracelet/bubbles/key" 8 | ) 9 | 10 | type VolMap struct { 11 | Remove key.Binding 12 | Prune key.Binding 13 | ShowDetails key.Binding 14 | ExitDetails key.Binding 15 | } 16 | 17 | func NewVolMap() VolMap { 18 | m := config.Cfg.Keybinds.Volumes 19 | return VolMap{ 20 | Remove: key.NewBinding( 21 | key.WithKeys(m.Remove), 22 | key.WithHelp(m.Remove, "remove"), 23 | ), 24 | Prune: key.NewBinding( 25 | key.WithKeys(m.Prune), 26 | key.WithHelp(m.Prune, "prune"), 27 | ), 28 | ShowDetails: key.NewBinding( 29 | key.WithKeys(m.ShowDetails), 30 | key.WithHelp(m.ShowDetails, "show details"), 31 | ), 32 | ExitDetails: key.NewBinding( 33 | key.WithKeys(m.ExitDetails), 34 | key.WithHelp(m.ExitDetails, "exit details"), 35 | ), 36 | } 37 | } 38 | 39 | func (m VolMap) Bindings() []key.Binding { 40 | var bindings []key.Binding 41 | 42 | v := reflect.ValueOf(m) 43 | for i := 0; i < v.NumField(); i++ { 44 | field := v.Field(i) 45 | if binding, ok := field.Interface().(key.Binding); ok { 46 | bindings = append(bindings, binding) 47 | } 48 | } 49 | 50 | return bindings 51 | } 52 | -------------------------------------------------------------------------------- /internal/models/msg/msg.go: -------------------------------------------------------------------------------- 1 | package msgpopup 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/NucleoFusion/cruise/internal/colors" 7 | "github.com/NucleoFusion/cruise/internal/utils" 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/lipgloss" 10 | ) 11 | 12 | type MsgPopup struct { 13 | Width int 14 | Height int 15 | Message string 16 | Title string 17 | Location string 18 | } 19 | 20 | func NewMsgPopup(w, h int, msg, title, location string) *MsgPopup { 21 | return &MsgPopup{ 22 | Width: w, 23 | Height: h, 24 | Message: utils.WrapAndLimit(msg, 20, 3), 25 | Title: title, 26 | Location: location, 27 | } 28 | } 29 | 30 | func (s *MsgPopup) Init() tea.Cmd { return nil } 31 | 32 | func (s *MsgPopup) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 33 | return s, nil 34 | } 35 | 36 | func (s *MsgPopup) View() string { 37 | style := lipgloss.NewStyle() 38 | 39 | text := fmt.Sprintf("%s\n\n%s", style.Foreground(colors.Load().MsgText).Render(s.Title+" | "+s.Location), 40 | style.Foreground(colors.Load().Text).Render(s.Message)) 41 | 42 | return lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(colors.Load().PopupBorder). 43 | Background(colors.Load().ErrorBg).Padding(1, 3).Render(text) 44 | } 45 | -------------------------------------------------------------------------------- /internal/models/error/error.go: -------------------------------------------------------------------------------- 1 | package errorpopup 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/NucleoFusion/cruise/internal/colors" 7 | "github.com/NucleoFusion/cruise/internal/utils" 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/lipgloss" 10 | ) 11 | 12 | type ErrorPopup struct { 13 | Width int 14 | Height int 15 | Message string 16 | Title string 17 | Location string 18 | } 19 | 20 | func NewErrorPopup(w, h int, msg, title, location string) *ErrorPopup { 21 | return &ErrorPopup{ 22 | Width: w, 23 | Height: h, 24 | Message: utils.WrapAndLimit(msg, 20, 3), 25 | Title: title, 26 | Location: location, 27 | } 28 | } 29 | 30 | func (s *ErrorPopup) Init() tea.Cmd { return nil } 31 | 32 | func (s *ErrorPopup) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 33 | return s, nil 34 | } 35 | 36 | func (s *ErrorPopup) View() string { 37 | style := lipgloss.NewStyle() 38 | 39 | text := fmt.Sprintf("%s\n\n%s", style.Foreground(colors.Load().ErrorText).Render(s.Title+" | "+s.Location), 40 | style.Foreground(colors.Load().Text).Render(s.Message)) 41 | 42 | return lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(colors.Load().PopupBorder). 43 | Background(colors.Load().ErrorBg).Padding(1, 3).Render(text) 44 | } 45 | -------------------------------------------------------------------------------- /cmd/dump.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/NucleoFusion/cruise/internal/config" 9 | "github.com/NucleoFusion/cruise/internal/utils" 10 | "github.com/pelletier/go-toml/v2" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | var dumpPath string 15 | 16 | var dumpCmd = &cobra.Command{ 17 | Use: "dump", 18 | Short: "Dump the config file at the given filepath.", 19 | Run: func(cmd *cobra.Command, args []string) { 20 | path, _ := cmd.Flags().GetString("path") 21 | fmt.Println("Dumping config to:", path) 22 | 23 | if filepath.Ext(path) == "" { 24 | path = filepath.Join(path, "dump.toml") 25 | } 26 | 27 | f, err := os.Create(path) 28 | if err != nil { 29 | fmt.Println("Error Creating File: ", err.Error()) 30 | return 31 | } 32 | defer f.Close() 33 | 34 | encoder := toml.NewEncoder(f) 35 | err = encoder.Encode(config.Default()) 36 | if err != nil { 37 | fmt.Println("Failed to write to file: ", err.Error()) 38 | return 39 | } 40 | 41 | fmt.Println("Successfully Dumped file to " + path) 42 | }, 43 | } 44 | 45 | func init() { 46 | dumpCmd.Flags().StringVarP(&dumpPath, "path", "p", filepath.Join(utils.GetCfgDir(), "dump.toml"), "filepath to dump the config") 47 | rootCmd.AddCommand(dumpCmd) 48 | } 49 | -------------------------------------------------------------------------------- /internal/styles/styles.go: -------------------------------------------------------------------------------- 1 | package styles 2 | 3 | import ( 4 | "github.com/NucleoFusion/cruise/internal/colors" 5 | "github.com/charmbracelet/lipgloss" 6 | ) 7 | 8 | func ErrorStyle() lipgloss.Style { 9 | return lipgloss.NewStyle().Background(colors.Load().ErrorBg).Foreground(colors.Load().ErrorText) 10 | } 11 | 12 | func PageStyle() lipgloss.Style { 13 | return lipgloss.NewStyle().Foreground(colors.Load().Text). 14 | Border(lipgloss.RoundedBorder()).BorderForeground(colors.Load().FocusedBorder) 15 | } 16 | 17 | func SubpageStyle() lipgloss.Style { 18 | return lipgloss.NewStyle().Foreground(colors.Load().Text). 19 | Border(lipgloss.RoundedBorder()).BorderForeground(colors.Load().UnfocusedBorder) 20 | } 21 | 22 | func TitleStyle() lipgloss.Style { 23 | return lipgloss.NewStyle().Background(colors.Load().SubtitleBg).Foreground(colors.Load().SubtitleText).Padding(0, 1) 24 | } 25 | 26 | func SelectedStyle() lipgloss.Style { 27 | return lipgloss.NewStyle().Background(colors.Load().MenuSelectedBg).Foreground(colors.Load().MenuSelectedText) 28 | } 29 | 30 | func DetailKeyStyle() lipgloss.Style { 31 | return lipgloss.NewStyle().Foreground(colors.Load().HelpKeyText).Background(colors.Load().HelpKeyBg) 32 | } 33 | 34 | func SceneStyle() lipgloss.Style { 35 | return lipgloss.NewStyle().Foreground(colors.Load().Text).Padding(0, 1) 36 | } 37 | -------------------------------------------------------------------------------- /internal/models/home/daemon.go: -------------------------------------------------------------------------------- 1 | package home 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/NucleoFusion/cruise/internal/docker" 7 | "github.com/NucleoFusion/cruise/internal/styles" 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/lipgloss" 10 | ) 11 | 12 | type Daemon struct { 13 | Width int 14 | Height int 15 | } 16 | 17 | func NewDaemon(w int, h int) *Daemon { 18 | return &Daemon{ 19 | Width: w, 20 | Height: h, 21 | } 22 | } 23 | 24 | func (s Daemon) Init() tea.Cmd { return nil } 25 | 26 | func (s Daemon) Update(msg tea.Msg) (Daemon, tea.Cmd) { 27 | return s, nil 28 | } 29 | 30 | func (s Daemon) View() string { 31 | return styles.SubpageStyle().PaddingTop(1).PaddingLeft(4).Render(lipgloss.JoinVertical(lipgloss.Center, 32 | styles.TitleStyle().Render("Daemon Status"), 33 | lipgloss.NewStyle(). 34 | Width(s.Width-6). //-6 from padding(4) and border(2) 35 | Height(s.Height-4). //-4 from title(1) border(2) and padding(1) 36 | Align(lipgloss.Left, lipgloss.Center). 37 | Render(s.FormattedView()))) 38 | } 39 | 40 | func (s Daemon) FormattedView() string { 41 | info, err := docker.GetDaemonInfo() 42 | if err != nil { 43 | return "Error Getting Daemon Status" 44 | } 45 | 46 | return fmt.Sprintf(` 47 | Docker Daemon Status: Running (%s) 48 | 49 | Uptime: %s | OS: %s 50 | `, info.Version, info.Uptime, info.OS) 51 | } 52 | -------------------------------------------------------------------------------- /internal/models/home/stats.go: -------------------------------------------------------------------------------- 1 | package home 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/NucleoFusion/cruise/internal/docker" 7 | "github.com/NucleoFusion/cruise/internal/styles" 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/lipgloss" 10 | ) 11 | 12 | type QuickStats struct { 13 | Width int 14 | Height int 15 | } 16 | 17 | func NewQuickStats(w int, h int) *QuickStats { 18 | return &QuickStats{ 19 | Width: w, 20 | Height: h, 21 | } 22 | } 23 | 24 | func (s QuickStats) Init() tea.Cmd { return nil } 25 | 26 | func (s QuickStats) Update(msg tea.Msg) (QuickStats, tea.Cmd) { 27 | return s, nil 28 | } 29 | 30 | func (s QuickStats) View() string { 31 | return styles.SubpageStyle().PaddingTop(1).PaddingLeft(4).Render(lipgloss.JoinVertical(lipgloss.Center, 32 | styles.TitleStyle().Render("Docker Stats"), 33 | lipgloss.NewStyle(). 34 | Width(s.Width-6). //-6 from padding(4) and border(2) 35 | Height(s.Height-4). //-4 from title(1) border(2) and padding(1) 36 | Align(lipgloss.Left, lipgloss.Center). 37 | Render(s.GetFormattedView()))) 38 | } 39 | 40 | func (s QuickStats) GetFormattedView() string { 41 | vols := docker.GetNumVolumes() 42 | cntnr := docker.GetNumContainers() 43 | imgs := docker.GetNumImages() 44 | ntwrks, _ := docker.GetNumNetworks() 45 | 46 | return fmt.Sprintf(` 47 | Containers: %d 48 | 49 | Images: %d 50 | 51 | Volumes: %d 52 | 53 | Networks: %d 54 | `, cntnr, imgs, vols, ntwrks) 55 | } 56 | -------------------------------------------------------------------------------- /internal/docker/volumes.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/NucleoFusion/cruise/internal/utils" 8 | "github.com/docker/docker/api/types/filters" 9 | "github.com/docker/docker/api/types/volume" 10 | ) 11 | 12 | func GetNumVolumes() int { 13 | vols, _ := GetVolumes() 14 | return len(vols.Volumes) 15 | } 16 | 17 | func GetVolumes() (volume.ListResponse, error) { 18 | return cli.VolumeList(context.Background(), volume.ListOptions{}) 19 | } 20 | 21 | func InspectVolume(id string) (volume.Volume, error) { 22 | return cli.VolumeInspect(context.Background(), id) 23 | } 24 | 25 | func PruneVolumes() error { 26 | _, err := cli.VolumesPrune(context.Background(), filters.NewArgs()) 27 | return err 28 | } 29 | 30 | func RemoveVolumes(id string) error { 31 | return cli.VolumeRemove(context.Background(), id, false) 32 | } 33 | 34 | func VolumesFormattedSummary(vol volume.Volume, width int) string { 35 | w := width / 11 36 | return fmt.Sprintf("%-*s %-*s %-*s %-*s %-*s", 37 | 3*w, utils.Shorten(vol.Name, 2*w), 38 | 1*w, utils.Shorten(vol.Scope, 2*w), 39 | 1*w, utils.Shorten(vol.Driver, 2*w), 40 | 3*w, utils.Shorten(vol.Mountpoint, 2*w), 41 | 3*w, utils.Shorten(vol.CreatedAt, 2*w)) 42 | } 43 | 44 | func VolumesHeaders(width int) string { 45 | w := width / 11 46 | return fmt.Sprintf("%-*s %-*s %-*s %-*s %-*s", 47 | 3*w, "Name", 48 | 1*w, "Scope", 49 | 1*w, "Driver", 50 | 3*w, "Mount Point", 51 | 3*w, "Created") 52 | } 53 | -------------------------------------------------------------------------------- /docs/docs/index.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | Welcome to Cruise, your powerful and user-friendly terminal interface for managing Docker. 4 | 5 | This Getting Started guide will help you quickly install Cruise, set it up, and launch the intuitive TUI so you can start managing your 6 | Docker containers, images, networks, and volumes with ease and efficiency. 7 | 8 | Whether you’re a developer, DevOps engineer, or system administrator, Cruise is designed to seamlessly integrate into your terminal-centric workflow and simplify 9 | your Docker experience. 10 | 11 |
12 | Screenshots 13 | 14 | ![screenshot](/1.png) 15 | ![screenshot](/2.png) 16 | ![screenshot](/3.png) 17 | ![screenshot](/4.png) 18 | ![screenshot](/5.png) 19 | ![screenshot](/6.png) 20 | ![screenshot](/7.png) 21 | ![screenshot](/8.png) 22 | ![screenshot](/9.png) 23 | ![screenshot](/10.png) 24 | ![screenshot](/11.png) 25 | 26 |
27 | 28 | ## Prerequisites 29 | 30 | - **Go** 31 | - **Docker** 32 | - **Trivy/Grype** (optional) 33 | 34 | ## Installation 35 | 36 | Refer to the installation docs [here](/docs/install) to install the latest version of cruise. 37 | 38 | ## Configuration 39 | 40 | To provide a detailed explanation on what configuration options are available, a `dump` command has been added, that dump's 41 | the default config to a given file. 42 | 43 | This file will also be available in the repository. Additionally, you can also refer to 44 | the configuration documentation [here](/docs/config/). 45 | -------------------------------------------------------------------------------- /docs/index.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitepress' 2 | 3 | // https://vitepress.dev/reference/site-config 4 | export default defineConfig({ 5 | title: "Cruise", 6 | description: "A minimal Docker TUI client", 7 | appearance: 'force-dark', 8 | base: '/cruise/', 9 | themeConfig: { 10 | // https://vitepress.dev/reference/default-theme-config 11 | nav: [ 12 | { text: 'Home', link: '/' }, 13 | { text: 'About', link: '/about/' }, 14 | { text: 'Docs', link: '/docs/' } 15 | ], 16 | 17 | sidebar: { 18 | "/": [ 19 | { text: "About", link: "/about/" }, 20 | { text: "Getting Started", link: "/docs/" }, 21 | { text: "Configuration", link: "/docs/config/" } 22 | ], 23 | "/docs/": [ 24 | { text: "Getting Started", link: "/docs/" }, 25 | { text: "Installation", link: "/docs/install" }, 26 | { text: "Configuration", link: "/docs/config/" }, 27 | { text: "Contributing", link: "/docs/contributing" }, 28 | ], 29 | "/docs/config/": [ 30 | { text: "Configuration", link: "/docs/config/" }, 31 | { text: "General", link: "/docs/config/general" }, 32 | { text: "Keybinds", link: "/docs/config/keybinds" }, 33 | { text: "Styles", link: "/docs/config/styles" }, 34 | ] 35 | }, 36 | 37 | socialLinks: [ 38 | { icon: 'github', link: 'https://github.com/NucleoFusion/cruise' } 39 | ], 40 | lastUpdated: { 41 | text: "Last Updated", 42 | }, 43 | search: { 44 | provider: 'local', 45 | } 46 | } 47 | }) 48 | -------------------------------------------------------------------------------- /internal/docker/networks.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/NucleoFusion/cruise/internal/utils" 8 | "github.com/docker/docker/api/types/filters" 9 | "github.com/docker/docker/api/types/network" 10 | ) 11 | 12 | func GetNumNetworks() (int, error) { 13 | nt, err := GetNetworks() 14 | return len(nt), err 15 | } 16 | 17 | func GetNetworks() ([]network.Summary, error) { 18 | networks, err := cli.NetworkList(context.Background(), network.ListOptions{}) 19 | if err != nil { 20 | return networks, err 21 | } 22 | 23 | return networks, nil 24 | } 25 | 26 | func PruneNetworks() error { 27 | _, err := cli.NetworksPrune(context.Background(), filters.NewArgs()) 28 | return err 29 | } 30 | 31 | func RemoveNetwork(id string) error { 32 | return cli.NetworkRemove(context.Background(), id) 33 | } 34 | 35 | func NetworksFormattedSummary(ntwrk network.Summary, width int) string { 36 | w := width / 14 37 | ipv := "✘" 38 | if ntwrk.EnableIPv4 { 39 | ipv = "✔" 40 | } 41 | return fmt.Sprintf("%-*s %-*s %-*s %-*s %-*s %-*s", 42 | 3*w, utils.Shorten(ntwrk.ID, 2*w), 43 | 3*w, utils.Shorten(ntwrk.Name, 3*w), 44 | 2*w, utils.Shorten(ntwrk.Scope, 2*w), 45 | 2*w, utils.Shorten(ntwrk.Driver, 2*w), 46 | 2*w, ipv, 47 | 2*w, fmt.Sprintf("%d", len(ntwrk.Containers))) 48 | } 49 | 50 | func NetworksHeaders(width int) string { 51 | w := width / 14 52 | return fmt.Sprintf("%-*s %-*s %-*s %-*s %-*s %-*s", 53 | 3*w, "ID", 54 | 3*w, "Name", 55 | 2*w, "Scope", 56 | 2*w, "Driver", 57 | 2*w, "IPv4", 58 | 2*w, "Container Count") 59 | } 60 | -------------------------------------------------------------------------------- /dump.toml: -------------------------------------------------------------------------------- 1 | [global] 2 | export_dir = '/home/nucleofusion/.cruise' 3 | term = 'konsole' 4 | 5 | [styles] 6 | text = '#cdd6f4' 7 | subtitle_text = '#74c7ec' 8 | subtitle_bg = '#313244' 9 | unfocused_border = '#45475a' 10 | focused_border = '#b4befe' 11 | help_key_bg = '#313244' 12 | help_key_text = '#1e1e2e' 13 | help_desc_text = '#6c7086' 14 | menu_selected_bg = '#b4befe' 15 | menu_selected_text = '#1e1e2e' 16 | error_text = '#f38ba8' 17 | error_bg = '#11111b' 18 | popup_border = '#74c7ec' 19 | placeholder_text = '#585b70' 20 | msg_text = '#74c7ec' 21 | 22 | [keybinds] 23 | [keybinds.global] 24 | page_finder = 'tab' 25 | list_up = 'up' 26 | list_down = 'down' 27 | focus_search = '/' 28 | unfocus_search = 'esc' 29 | 30 | [keybinds.container] 31 | start = 's' 32 | exec = 'e' 33 | restart = 'r' 34 | stop = 't' 35 | remove = 'd' 36 | pause = 'p' 37 | unpause = 'u' 38 | port_map = 'm' 39 | show_details = 'enter' 40 | exit_details = 'esc' 41 | 42 | [keybinds.images] 43 | remove = 'r' 44 | prune = 'd' 45 | push = 'p' 46 | pull = 'u' 47 | build = 'b' 48 | layers = 'l' 49 | exit = '' 50 | 51 | [keybinds.fzf] 52 | up = 'up' 53 | down = 'down' 54 | enter = 'enter' 55 | exit = 'esc' 56 | 57 | [keybinds.monitoring] 58 | search = '/' 59 | exit_search = 'esc' 60 | export = '' 61 | 62 | [keybinds.network] 63 | remove = 'r' 64 | prune = 'p' 65 | show_details = 'enter' 66 | exit_details = 'esc' 67 | 68 | [keybinds.volume] 69 | remove = 'r' 70 | prune = 'p' 71 | show_details = 'enter' 72 | exit_details = 'esc' 73 | 74 | [keybinds.vulnerability] 75 | focus_scanners = 'S' 76 | focus_list = 'L' 77 | export = '' 78 | -------------------------------------------------------------------------------- /docs/docs/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for considering a contribution! We welcome bug reports, feature requests, code, docs, and suggestions. 4 | 5 | ## Dependencies 6 | 7 | - Go 8 | - Trivy 9 | - Grype 10 | - Docker 11 | 12 | ## Getting Started 13 | 14 | 1. Fork the repository. 15 | 2. Clone your fork. 16 | - ` git clone .git` 17 | 3. Create a branch for the respective issue. 18 | - `git checkout -b dev` 19 | - \ should follow name of feat/newpage, bug/#11 or docs/typo etc. 20 | 5. When Creating a PR, make sure to reference the respective issue. 21 | 22 | ## Issues 23 | - Search open/closed issues before opening a new one. 24 | - For bugs, include OS, Docker version, logs, and steps to reproduce. 25 | - For features, describe the problem and proposed solution. 26 | 27 | ## Making Changes 28 | - Create a feature/fix branch from `main`. 29 | - Use clear, descriptive commit messages. 30 | - Format your code with `gofmt`, and check with `golangci-lint`. 31 | - Commit Message should follow format \:\, where type is like feat, bug, enhancement, chore etc. 32 | 33 | ## Pull Requests 34 | - Make PR to dev branch only!! 35 | - Each PR should focus on one change. 36 | - Link relevant issues in the PR. 37 | - Respond to reviewer comments. 38 | 39 | ## Help & Feedback 40 | 41 | Questions? Use GitHub Discussions or open an issue with the “question” label. 42 | 43 | ## Thanks! 44 | All contributors are credited in the [Credits](https://github.com/NucleoFusion/cruise?tab=readme-ov-file#-description) section. We appreciate your help! 45 | -------------------------------------------------------------------------------- /internal/keymap/images.go: -------------------------------------------------------------------------------- 1 | package keymap 2 | 3 | import ( 4 | "reflect" 5 | 6 | "github.com/NucleoFusion/cruise/internal/config" 7 | "github.com/charmbracelet/bubbles/key" 8 | ) 9 | 10 | type ImagesMap struct { 11 | Remove key.Binding 12 | Prune key.Binding 13 | Push key.Binding 14 | Pull key.Binding 15 | Build key.Binding 16 | Layers key.Binding 17 | Exit key.Binding 18 | Sync key.Binding 19 | } 20 | 21 | func NewImagesMap() ImagesMap { 22 | m := config.Cfg.Keybinds.Images 23 | return ImagesMap{ 24 | Remove: key.NewBinding( 25 | key.WithKeys(m.Remove), 26 | key.WithHelp(m.Remove, "remove"), 27 | ), 28 | Prune: key.NewBinding( 29 | key.WithKeys(m.Prune), 30 | key.WithHelp(m.Prune, "prune"), 31 | ), 32 | Pull: key.NewBinding( 33 | key.WithKeys(m.Pull), 34 | key.WithHelp(m.Pull, "pull"), 35 | ), 36 | Push: key.NewBinding( 37 | key.WithKeys(m.Push), 38 | key.WithHelp(m.Push, "push"), 39 | ), 40 | Build: key.NewBinding( 41 | key.WithKeys(m.Build), 42 | key.WithHelp(m.Build, "build"), 43 | ), 44 | Layers: key.NewBinding( 45 | key.WithKeys(m.Layers), 46 | key.WithHelp(m.Layers, "layers"), 47 | ), 48 | Sync: key.NewBinding( 49 | key.WithKeys(m.Sync), 50 | key.WithHelp(m.Sync, "sync"), 51 | ), 52 | } 53 | } 54 | 55 | func (m ImagesMap) Bindings() []key.Binding { 56 | var bindings []key.Binding 57 | 58 | v := reflect.ValueOf(m) 59 | for i := 0; i < v.NumField(); i++ { 60 | field := v.Field(i) 61 | if binding, ok := field.Interface().(key.Binding); ok { 62 | bindings = append(bindings, binding) 63 | } 64 | } 65 | 66 | return bindings 67 | } 68 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for considering a contribution! We welcome bug reports, feature requests, code, docs, and suggestions. 4 | 5 | ## Dependencies 6 | 7 | - Go 8 | - Trivy 9 | - Grype 10 | - Docker 11 | 12 | ## Getting Started 13 | 14 | 1. Fork the repository. 15 | 2. Clone your fork. 16 | - ` git clone .git` 17 | 3. Create a branch for the respective issue. 18 | - `git checkout -b dev` 19 | - \ should follow name of feat/newpage, bug/#11 or docs/typo etc. 20 | 5. When Creating a PR, make sure to reference the respective issue. 21 | 22 | ## Issues 23 | - Search open/closed issues before opening a new one. 24 | - For bugs, include OS, Docker version, logs, and steps to reproduce. 25 | - For features, describe the problem and proposed solution. 26 | 27 | ## Making Changes 28 | - Create a feature/fix branch from `main`. 29 | - Use clear, descriptive commit messages. 30 | - Format your code with `gofmt`, and check with `golangci-lint`. 31 | - Commit Message should follow format \:\, where type is like feat, bug, enhancement, chore etc. 32 | 33 | ## Pull Requests 34 | - Make PR to dev branch only!! 35 | - Each PR should focus on one change. 36 | - Link relevant issues in the PR. 37 | - Respond to reviewer comments. 38 | 39 | ## Code of Conduct 40 | All contributors must follow our [Code of Conduct](CODE_OF_CONDUCT.md). 41 | 42 | 43 | ## Help & Feedback 44 | 45 | Questions? Use GitHub Discussions or open an issue with the “question” label. 46 | 47 | ## Thanks! 48 | All contributors are credited in the [Credits](README.md#credits) section. We appreciate your help! 49 | -------------------------------------------------------------------------------- /internal/colors/colors.go: -------------------------------------------------------------------------------- 1 | package colors 2 | 3 | import ( 4 | "github.com/NucleoFusion/cruise/internal/config" 5 | "github.com/charmbracelet/lipgloss" 6 | ) 7 | 8 | type ColorPalette struct { 9 | Text lipgloss.Color 10 | SubtitleText lipgloss.Color 11 | SubtitleBg lipgloss.Color 12 | UnfocusedBorder lipgloss.Color 13 | FocusedBorder lipgloss.Color 14 | HelpKeyBg lipgloss.Color 15 | HelpKeyText lipgloss.Color 16 | HelpDescText lipgloss.Color 17 | MenuSelectedBg lipgloss.Color 18 | MenuSelectedText lipgloss.Color 19 | ErrorText lipgloss.Color 20 | ErrorBg lipgloss.Color 21 | PopupBorder lipgloss.Color 22 | PlaceholderText lipgloss.Color 23 | MsgText lipgloss.Color 24 | } 25 | 26 | func Load() ColorPalette { 27 | s := config.Cfg.Styles 28 | return ColorPalette{ 29 | UnfocusedBorder: lipgloss.Color(s.UnfocusedBorder), 30 | FocusedBorder: lipgloss.Color(s.FocusedBorder), 31 | SubtitleText: lipgloss.Color(s.SubtitleText), 32 | SubtitleBg: lipgloss.Color(s.SubtitleBg), 33 | HelpKeyBg: lipgloss.Color(s.HelpKeyBg), 34 | HelpKeyText: lipgloss.Color(s.HelpKeyText), 35 | HelpDescText: lipgloss.Color(s.HelpDescText), 36 | MenuSelectedBg: lipgloss.Color(s.MenuSelectedBg), 37 | MenuSelectedText: lipgloss.Color(s.MenuSelectedText), 38 | ErrorText: lipgloss.Color(s.ErrorText), 39 | ErrorBg: lipgloss.Color(s.ErrorBg), 40 | PopupBorder: lipgloss.Color(s.PopupBorder), 41 | Text: lipgloss.Color(s.Text), 42 | PlaceholderText: lipgloss.Color(s.PlaceholderText), 43 | MsgText: lipgloss.Color(s.MsgText), 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /docs/docs/install.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | ## Archives 4 | 5 | Latest archives (zip/tar.gz files) are available on the github [releases](https://github.com/NucleoFusion/cruise/releases) page. 6 | Just download the file for your system and run the following commands to extract it. 7 | 8 | ### Linux / MacOS 9 | 10 | ```bash 11 | tar -xvf ./path/to/file.tar.gz -C /extract/in/directory/ 12 | ``` 13 | 14 | ### Windows 15 | 16 | ```bash 17 | # PowerShell (built-in) 18 | Expand-Archive -Path path\to\file.zip -DestinationPath "C:\extract\to\directory" 19 | 20 | # Command Prompt (if using tar/bsdtar) 21 | tar -xf path\to\file.zip -C "C:\extract\to\directory" 22 | 23 | ``` 24 | 25 | ## Debian / RPM 26 | 27 | ### Debian / Ubuntu (.deb) 28 | 29 | Install the .deb file [_here_](https://github.com/NucleoFusion/cruise/releases). 30 | 31 | ```bash 32 | sudo apt install ./path/to/file.deb 33 | ``` 34 | > _maintained by [NucleoFusion](https://github.com/NucleoFusion)_ 35 | 36 | ### Fedora / RHEL (.dnf) 37 | 38 | Install the .rpm file [_here_](https://github.com/NucleoFusion/cruise/releases). 39 | 40 | ```bash 41 | sudo dnf install ./path/to/file.dnf 42 | ``` 43 | > _maintained by [NucleoFusion](https://github.com/NucleoFusion)_ 44 | 45 | ## Homebrew 46 | 47 | _Cruise_ can be installed via Homebrew using the, 48 | 49 | ```bash 50 | brew tap NucleoFusion/homebrew-tap 51 | brew install --cask cruise 52 | ``` 53 | 54 | 55 | ## AUR 56 | 57 | Arch users can install _cruise_ through the AUR. using any AUR Helper. 58 | 59 | ```bash 60 | yay -S cruise 61 | ``` 62 | 63 | ## NixOS 64 | NixOS users can install __cruise__ by adding it to their `systemPackages`: 65 | ```nix 66 | environment.systemPackages = with pkgs; [ 67 | cruise 68 | ]; 69 | ``` 70 | 71 | -------------------------------------------------------------------------------- /docs/.vitepress/cache/deps/_metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "hash": "f503e7ee", 3 | "configHash": "ac29fac8", 4 | "lockfileHash": "cf52a0a5", 5 | "browserHash": "bb3666fb", 6 | "optimized": { 7 | "vue": { 8 | "src": "../../../node_modules/vue/dist/vue.runtime.esm-bundler.js", 9 | "file": "vue.js", 10 | "fileHash": "a58aa7ae", 11 | "needsInterop": false 12 | }, 13 | "vitepress > @vue/devtools-api": { 14 | "src": "../../../node_modules/@vue/devtools-api/dist/index.js", 15 | "file": "vitepress___@vue_devtools-api.js", 16 | "fileHash": "92cd512d", 17 | "needsInterop": false 18 | }, 19 | "vitepress > @vueuse/core": { 20 | "src": "../../../node_modules/@vueuse/core/index.mjs", 21 | "file": "vitepress___@vueuse_core.js", 22 | "fileHash": "501bd1b8", 23 | "needsInterop": false 24 | }, 25 | "vitepress > @vueuse/integrations/useFocusTrap": { 26 | "src": "../../../node_modules/@vueuse/integrations/useFocusTrap.mjs", 27 | "file": "vitepress___@vueuse_integrations_useFocusTrap.js", 28 | "fileHash": "26fd9494", 29 | "needsInterop": false 30 | }, 31 | "vitepress > mark.js/src/vanilla.js": { 32 | "src": "../../../node_modules/mark.js/src/vanilla.js", 33 | "file": "vitepress___mark__js_src_vanilla__js.js", 34 | "fileHash": "c28f7ced", 35 | "needsInterop": false 36 | }, 37 | "vitepress > minisearch": { 38 | "src": "../../../node_modules/minisearch/dist/es/index.js", 39 | "file": "vitepress___minisearch.js", 40 | "fileHash": "a5e510bb", 41 | "needsInterop": false 42 | } 43 | }, 44 | "chunks": { 45 | "chunk-AKQBXH3S": { 46 | "file": "chunk-AKQBXH3S.js" 47 | }, 48 | "chunk-EAEFJUV4": { 49 | "file": "chunk-EAEFJUV4.js" 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /internal/keymap/navmap.go: -------------------------------------------------------------------------------- 1 | package keymap 2 | 3 | import ( 4 | "reflect" 5 | 6 | "github.com/NucleoFusion/cruise/internal/config" 7 | "github.com/charmbracelet/bubbles/key" 8 | ) 9 | 10 | type NavMap struct { 11 | Exit key.Binding 12 | Dashboard key.Binding 13 | Containers key.Binding 14 | Images key.Binding 15 | Networks key.Binding 16 | Volumes key.Binding 17 | Monitoring key.Binding 18 | Vulnerability key.Binding 19 | } 20 | 21 | func NewNavMap() NavMap { 22 | m := config.Cfg.Keybinds.Nav 23 | return NavMap{ 24 | Exit: key.NewBinding( 25 | key.WithKeys(m.Exit), 26 | key.WithHelp(m.Exit, "exit"), 27 | ), 28 | Dashboard: key.NewBinding( 29 | key.WithKeys(m.Dashboard), 30 | key.WithHelp(m.Dashboard, "dashboard"), 31 | ), 32 | Containers: key.NewBinding( 33 | key.WithKeys(m.Containers), 34 | key.WithHelp(m.Containers, "containers"), 35 | ), 36 | Images: key.NewBinding( 37 | key.WithKeys(m.Images), 38 | key.WithHelp(m.Images, "images"), 39 | ), 40 | Networks: key.NewBinding( 41 | key.WithKeys(m.Networks), 42 | key.WithHelp(m.Networks, "networks"), 43 | ), 44 | Volumes: key.NewBinding( 45 | key.WithKeys(m.Volumes), 46 | key.WithHelp(m.Volumes, "volumes"), 47 | ), 48 | Monitoring: key.NewBinding( 49 | key.WithKeys(m.Monitoring), 50 | key.WithHelp(m.Monitoring, "monitoring"), 51 | ), 52 | Vulnerability: key.NewBinding( 53 | key.WithKeys(m.Vulnerability), 54 | key.WithHelp(m.Vulnerability, "vulnerability"), 55 | ), 56 | } 57 | } 58 | 59 | func (m NavMap) Bindings() []key.Binding { 60 | var bindings []key.Binding 61 | 62 | v := reflect.ValueOf(m) 63 | for i := 0; i < v.NumField(); i++ { 64 | field := v.Field(i) 65 | if binding, ok := field.Interface().(key.Binding); ok { 66 | bindings = append(bindings, binding) 67 | } 68 | } 69 | 70 | return bindings 71 | } 72 | -------------------------------------------------------------------------------- /internal/models/help/help.go: -------------------------------------------------------------------------------- 1 | package styledhelp 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/NucleoFusion/cruise/internal/colors" 8 | "github.com/NucleoFusion/cruise/internal/keymap" 9 | "github.com/NucleoFusion/cruise/internal/styles" 10 | "github.com/charmbracelet/bubbles/help" 11 | "github.com/charmbracelet/bubbles/key" 12 | "github.com/charmbracelet/bubbles/viewport" 13 | "github.com/charmbracelet/lipgloss" 14 | ) 15 | 16 | type StyledHelp struct { 17 | Width int 18 | styledhelp help.Model 19 | kmap help.KeyMap 20 | vp viewport.Model 21 | } 22 | 23 | func NewStyledHelp(b []key.Binding, w int) StyledHelp { 24 | h := help.New() 25 | h.Styles.FullKey = h.Styles.FullKey.Margin(0, 0).Padding(0, 0) 26 | h.Styles.FullDesc = h.Styles.FullDesc.Margin(0, 0).Padding(0, 0) 27 | h.Styles.ShortKey = lipgloss.NewStyle().Background(colors.Load().HelpKeyBg).Foreground(colors.Load().HelpKeyText) 28 | h.Styles.ShortDesc = lipgloss.NewStyle().Foreground(colors.Load().HelpDescText) 29 | 30 | newk := make([]key.Binding, 0, len(b)) 31 | newk = append(newk, key.NewBinding(key.WithKeys("tab"), key.WithHelp(" tab ", "switch pages")), keymap.QuickQuitKey()) 32 | for _, bind := range b { 33 | newk = append(newk, padBinding(bind)) 34 | } 35 | 36 | vp := viewport.New(w, 3) 37 | vp.Style = styles.SubpageStyle() 38 | 39 | return StyledHelp{ 40 | Width: w - 4, 41 | styledhelp: h, 42 | kmap: keymap.NewDynamic(newk), 43 | vp: vp, 44 | } 45 | } 46 | 47 | func (s *StyledHelp) View() string { 48 | s.vp.SetContent(lipgloss.PlaceHorizontal(s.Width-2, lipgloss.Center, strings.Trim(s.styledhelp.View(s.kmap), "\n"))) 49 | return s.vp.View() 50 | } 51 | 52 | func padBinding(b key.Binding) key.Binding { 53 | k := fmt.Sprintf(" %s ", b.Help().Key) // pad to fixed width 54 | return key.NewBinding( 55 | key.WithKeys(b.Keys()...), 56 | key.WithHelp(k, b.Help().Desc), 57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /internal/keymap/containers.go: -------------------------------------------------------------------------------- 1 | package keymap 2 | 3 | import ( 4 | "reflect" 5 | 6 | "github.com/NucleoFusion/cruise/internal/config" 7 | "github.com/charmbracelet/bubbles/key" 8 | ) 9 | 10 | type ContainersMap struct { 11 | Start key.Binding 12 | Exec key.Binding 13 | Restart key.Binding 14 | Stop key.Binding 15 | Remove key.Binding 16 | Pause key.Binding 17 | Unpause key.Binding 18 | PortMap key.Binding 19 | ShowDetails key.Binding 20 | ExitDetails key.Binding 21 | } 22 | 23 | func NewContainersMap() ContainersMap { 24 | m := config.Cfg.Keybinds.Container 25 | return ContainersMap{ 26 | Start: key.NewBinding( 27 | key.WithKeys(m.Start), 28 | key.WithHelp(m.Start, "start"), 29 | ), 30 | Stop: key.NewBinding( 31 | key.WithKeys(m.Stop), 32 | key.WithHelp(m.Stop, "stop"), 33 | ), 34 | Remove: key.NewBinding( 35 | key.WithKeys(m.Remove), 36 | key.WithHelp(m.Remove, "remove"), 37 | ), 38 | Pause: key.NewBinding( 39 | key.WithKeys(m.Pause), 40 | key.WithHelp(m.Pause, "pause"), 41 | ), 42 | Unpause: key.NewBinding( 43 | key.WithKeys(m.Unpause), 44 | key.WithHelp(m.Unpause, "unpause"), 45 | ), 46 | Restart: key.NewBinding( 47 | key.WithKeys(m.Restart), 48 | key.WithHelp(m.Restart, "restart"), 49 | ), 50 | Exec: key.NewBinding( 51 | key.WithKeys(m.Exec), 52 | key.WithHelp(m.Exec, "exec -it"), 53 | ), 54 | PortMap: key.NewBinding( 55 | key.WithKeys(m.PortMap), 56 | key.WithHelp(m.PortMap, "port map"), 57 | ), 58 | ShowDetails: key.NewBinding( 59 | key.WithKeys(m.ShowDetails), 60 | key.WithHelp(m.ShowDetails, "show detail"), 61 | ), 62 | ExitDetails: key.NewBinding( 63 | key.WithKeys(m.ExitDetails), 64 | key.WithHelp(m.ExitDetails, "exit detail"), 65 | ), 66 | } 67 | } 68 | 69 | func (m ContainersMap) Bindings() []key.Binding { 70 | var bindings []key.Binding 71 | 72 | v := reflect.ValueOf(m) 73 | for i := 0; i < v.NumField(); i++ { 74 | field := v.Field(i) 75 | if binding, ok := field.Interface().(key.Binding); ok { 76 | bindings = append(bindings, binding) 77 | } 78 | } 79 | 80 | return bindings 81 | } 82 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /* Backgrounds */ 3 | --vp-c-bg: #1e1e2e; /* base */ 4 | --vp-c-bg-alt: #181825; /* mantle */ 5 | --vp-c-bg-elv: #1e1e2e; /* base */ 6 | --vp-c-bg-soft: #11111b; /* crust */ 7 | 8 | /* Borders */ 9 | --vp-c-border: #313244; /* surface0 */ 10 | --vp-c-divider: #45475a; /* surface1 */ 11 | --vp-c-gutter: #585b70; /* surface2 */ 12 | 13 | /* Text */ 14 | --vp-c-text-1: #cdd6f4; /* text */ 15 | --vp-c-text-2: #bac2de; /* subtext1 */ 16 | --vp-c-text-3: #a6adc8; /* subtext0 */ 17 | 18 | /* Brand / Accent */ 19 | --vp-c-brand-1: #89dceb; 20 | --vp-c-brand-2: #74c7ec; 21 | --vp-c-brand-3: #89b4fa; 22 | 23 | /* Semantic Colors */ 24 | --vp-c-green-1: #a6e3a1; 25 | --vp-c-green-2: #94e2d5; 26 | --vp-c-green-3: #74c7ec; 27 | 28 | --vp-c-purple-1: #cba6f7; 29 | --vp-c-purple-2: #b4befe; 30 | --vp-c-purple-3: #a6adc8; 31 | 32 | --vp-c-yellow-1: #f9e2af; 33 | --vp-c-yellow-2: #f5e0dc; 34 | --vp-c-yellow-3: #fab387; 35 | 36 | --vp-c-red-1: #f38ba8; 37 | --vp-c-red-2: #eba0ac; 38 | --vp-c-red-3: #f38ba8; 39 | 40 | --vp-c-gray-1: #313244; /* surface0 */ 41 | --vp-c-gray-2: #45475a; /* surface1 */ 42 | --vp-c-gray-3: #585b70; /* surface2 */ 43 | --vp-c-gray-soft: rgba(205, 214, 244, 0.1); /* faded text */ 44 | 45 | /* Sponsor */ 46 | --vp-c-sponsor: #f5c2e7; /* pink */ 47 | } 48 | 49 | .dark { 50 | --vp-c-bg: #1e1e2e; 51 | --vp-c-bg-alt: #181825; 52 | --vp-c-bg-elv: #1e1e2e; 53 | --vp-c-bg-soft: #11111b; 54 | 55 | --vp-c-border: #313244; 56 | --vp-c-divider: #45475a; 57 | --vp-c-gutter: #585b70; 58 | 59 | --vp-c-text-1: #cdd6f4; 60 | --vp-c-text-2: #bac2de; 61 | --vp-c-text-3: #a6adc8; 62 | 63 | --vp-c-brand-1: #89dceb; 64 | --vp-c-brand-2: #74c7ec; 65 | --vp-c-brand-3: #89b4fa; 66 | } 67 | 68 | .VPNavBar .VPNavBarTitle { 69 | font-size: 1.5em; 70 | font-weight: bold; 71 | background: linear-gradient(120deg, var(--vp-c-brand-2), var(--vp-c-brand-1)); 72 | -webkit-background-clip: text; 73 | -webkit-text-fill-color: transparent; 74 | background-clip: text; 75 | color: transparent; 76 | } 77 | 78 | img { 79 | border: 1px solid #cdd6f4; 80 | /* border-radius: 8px; */ 81 | margin-bottom: 1vh; 82 | padding: 4px; 83 | } 84 | /* .VPSwitchAppearance { */ 85 | /* display: none !important; */ 86 | /* } */ 87 | -------------------------------------------------------------------------------- /internal/models/home/home.go: -------------------------------------------------------------------------------- 1 | package home 2 | 3 | import ( 4 | "github.com/NucleoFusion/cruise/internal/keymap" 5 | "github.com/NucleoFusion/cruise/internal/messages" 6 | styledhelp "github.com/NucleoFusion/cruise/internal/models/help" 7 | "github.com/NucleoFusion/cruise/internal/styles" 8 | "github.com/charmbracelet/bubbles/key" 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/charmbracelet/lipgloss" 11 | ) 12 | 13 | type Home struct { 14 | Width int 15 | Height int 16 | Logs *Logs 17 | Daemon *Daemon 18 | SysRes *SysRes 19 | QuickStats *QuickStats 20 | Help styledhelp.StyledHelp 21 | } 22 | 23 | func NewHome(w int, h int) *Home { 24 | return &Home{ 25 | Width: w, 26 | Height: h, 27 | Logs: NewLogs((w-2)-(w-2)/4, (h-14)-(h-14)/2), //h-15 to account for styled help and title, w-2 for scene padding 28 | Daemon: NewDaemon((w-2)/4, (h-14)-(h-14)/2), 29 | SysRes: NewSysRes((w-2)-(w-2)/4, (h-14)/2), 30 | QuickStats: NewQuickStats((w-2)/4, (h-14)/2), 31 | Help: styledhelp.NewStyledHelp([]key.Binding{}, w-2), 32 | } 33 | } 34 | 35 | func (s *Home) Init() tea.Cmd { 36 | return tea.Batch(s.SysRes.Init(), s.Logs.Init(), messages.TickDashboard()) 37 | } 38 | 39 | func (s *Home) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 40 | switch msg := msg.(type) { 41 | 42 | case messages.DashboardTick: 43 | cmd := s.Refresh() 44 | return s, tea.Batch(cmd, messages.TickDashboard()) 45 | case messages.SysResReadyMsg: 46 | var cmd tea.Cmd 47 | s.SysRes, cmd = s.SysRes.Update(msg) 48 | return s, cmd 49 | case messages.NewEvents: 50 | var cmd tea.Cmd 51 | s.Logs, cmd = s.Logs.Update(msg) 52 | return s, cmd 53 | case tea.KeyMsg: 54 | switch { 55 | case key.Matches(msg, keymap.QuickQuitKey()): 56 | return s, tea.Quit 57 | } 58 | } 59 | return s, nil 60 | } 61 | 62 | func (s *Home) View() string { 63 | logo := lipgloss.Place(s.Width-2, 11, //use fixed height for title 64 | lipgloss.Center, lipgloss.Center, styles.TextStyle().Render(styles.LogoText)) 65 | sysres := s.SysRes.View() 66 | daemon := s.Daemon.View() 67 | stats := s.QuickStats.View() 68 | logs := s.Logs.View() 69 | 70 | view := lipgloss.JoinVertical(lipgloss.Center, logo, 71 | lipgloss.JoinHorizontal(lipgloss.Center, sysres, stats), 72 | lipgloss.JoinHorizontal(lipgloss.Center, daemon, logs), 73 | s.Help.View(), 74 | ) 75 | return styles.SceneStyle().Render(view) 76 | } 77 | 78 | func (s *Home) Refresh() tea.Cmd { 79 | return tea.Batch(s.SysRes.Refresh()) 80 | } 81 | -------------------------------------------------------------------------------- /internal/messages/messages.go: -------------------------------------------------------------------------------- 1 | package messages 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "time" 7 | 8 | "github.com/NucleoFusion/cruise/internal/data" 9 | "github.com/NucleoFusion/cruise/internal/enums" 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/docker/docker/api/types/container" 12 | "github.com/docker/docker/api/types/events" 13 | "github.com/docker/docker/api/types/image" 14 | "github.com/docker/docker/api/types/network" 15 | "github.com/docker/docker/api/types/volume" 16 | ) 17 | 18 | type DashboardTick time.Time 19 | 20 | func TickDashboard() tea.Cmd { 21 | return tea.Tick(5*time.Second, func(t time.Time) tea.Msg { 22 | return DashboardTick(t) 23 | }) 24 | } 25 | 26 | type ( 27 | ChangePg struct { 28 | Pg enums.PageType 29 | Exited bool 30 | } 31 | 32 | CloseDetails struct{} 33 | 34 | PortMapMsg struct { 35 | Arr []string 36 | Err error 37 | } 38 | 39 | ErrorMsg struct { 40 | Title string 41 | Msg string 42 | Locn string 43 | } 44 | 45 | CloseError struct{} 46 | 47 | MsgPopup struct { 48 | Title string 49 | Msg string 50 | Locn string 51 | } 52 | 53 | CloseMsgPopup struct{} 54 | 55 | StartScanMsg struct { 56 | Img string 57 | } 58 | 59 | ScanResponse struct { 60 | Arr []any 61 | Err error 62 | } 63 | 64 | ScannerListMsg struct { 65 | Found []bool 66 | } 67 | 68 | NewContainerDetails struct { 69 | Stats container.StatsResponseReader 70 | Decoder *json.Decoder 71 | Logs *io.ReadCloser 72 | } 73 | 74 | ContainerDetailsReady struct { 75 | Stats container.StatsResponseReader 76 | Decoder *json.Decoder 77 | Logs *io.ReadCloser 78 | } 79 | 80 | ContainerDetailsTick struct{} 81 | 82 | ContainerReadyMsg struct { 83 | Items []container.Summary 84 | Err error 85 | } 86 | 87 | ImagesReadyMsg struct { 88 | Map map[string]image.Summary 89 | } 90 | 91 | UpdateImagesMsg struct { 92 | Items []image.Summary 93 | } 94 | 95 | NetworksReadyMsg struct { 96 | Items []network.Summary 97 | } 98 | 99 | VolumesReadyMsg struct { 100 | Items []*volume.Volume 101 | } 102 | 103 | UpdateNetworksMsg struct{} 104 | 105 | FzfSelection struct { 106 | Selection string 107 | Exited bool 108 | } 109 | 110 | SysResReadyMsg struct { 111 | CPU *data.CPUInfo 112 | Mem *data.MemInfo 113 | Disk *data.DiskInfo 114 | } 115 | 116 | NewEvents struct { 117 | Events []*events.Message 118 | } 119 | 120 | DaemonReadyMsg struct { 121 | Err error 122 | } 123 | ) 124 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | As contributors and maintainers of this project, and in the interest of fostering an open and welcoming community, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to participating in this project as a harassment-free experience for everyone, regardless of the level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, or nationality. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to a positive environment include: 10 | - Demonstrating empathy and kindness toward others 11 | - Being respectful of differing viewpoints and experiences 12 | - Giving and gracefully accepting constructive feedback 13 | - Focusing on what is best for the community 14 | - Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 15 | 16 | Examples of unacceptable behavior by participants include: 17 | - The use of sexualized language or imagery and unwelcome attention or advances 18 | - Trolling, insulting/derogatory comments, and personal or political attacks 19 | - Public or private harassment 20 | - Publishing others' private information without explicit permission 21 | - Other conduct which could reasonably be considered inappropriate in a professional setting 22 | 23 | ## Scope 24 | 25 | This Code of Conduct applies within project spaces and in public spaces when an individual is representing the project or its community. 26 | 27 | ## Enforcement 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at [lakshit.singh.mail@gmail.com]. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. 32 | 33 | Project maintainers are obligated to maintain confidentiality with regard to the reporter of an incident. 34 | 35 | ## Attribution 36 | 37 | This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1, and the [CNCF Code of Conduct](https://github.com/cncf/foundation/blob/main/code-of-conduct.md). -------------------------------------------------------------------------------- /internal/styles/text.go: -------------------------------------------------------------------------------- 1 | package styles 2 | 3 | import ( 4 | "github.com/NucleoFusion/cruise/internal/colors" 5 | "github.com/charmbracelet/lipgloss" 6 | ) 7 | 8 | var LogoText = ` 9 | ______ _ 10 | / ____/_____ __ __ (_)_____ ___ 11 | / / / ___// / / // // ___// _ \ 12 | / /___ / / / /_/ // /(__ )/ __/ 13 | \____//_/ \__,_//_//____/ \___/ ` 14 | 15 | var ContainersText = ` 16 | ______ __ _ 17 | / ____/____ ____ / /_ ____ _ (_)____ ___ _____ _____ 18 | / / / __ \ / __ \ / __// __ '// // __ \ / _ \ / ___// ___/ 19 | / /___ / /_/ // / / // /_ / /_/ // // / / // __// / (__ ) 20 | \____/ \____//_/ /_/ \__/ \__,_//_//_/ /_/ \___//_/ /____/ ` 21 | 22 | var ImagesText = ` 23 | ____ 24 | / _/___ ___ ____ _____ ____ _____ 25 | / // __ '__ \/ __ '/ __ '/ _ \/ ___/ 26 | _/ // / / / / / /_/ / /_/ / __(__ ) 27 | /___/_/ /_/ /_/\__,_/\__, /\___/____/ 28 | /____/ ` 29 | 30 | var VulnerabilityText = ` 31 | _ __ __ __ _ __ _ __ 32 | | | / /__ __ / /____ ___ _____ ____ _ / /_ (_)/ /(_)/ /_ __ __ 33 | | | / // / / // // __ \ / _ \ / ___// __ '// __ \ / // // // __// / / / 34 | | |/ // /_/ // // / / // __// / / /_/ // /_/ // // // // /_ / /_/ / 35 | |___/ \__,_//_//_/ /_/ \___//_/ \__,_//_.___//_//_//_/ \__/ \__, / 36 | /____/ ` 37 | 38 | var MonitoringText = ` 39 | __ ___ _ __ _ 40 | / |/ /____ ____ (_)/ /_ ____ _____ (_)____ ____ _ 41 | / /|_/ // __ \ / __ \ / // __// __ \ / ___// // __ \ / __ '/ 42 | / / / // /_/ // / / // // /_ / /_/ // / / // / / // /_/ / 43 | /_/ /_/ \____//_/ /_//_/ \__/ \____//_/ /_//_/ /_/ \__, / 44 | /____/` 45 | 46 | var NetworksText = ` 47 | _ __ __ __ 48 | / | / /___ / /_ _ __ ____ _____ / /__ _____ 49 | / |/ // _ \ / __/| | /| / // __ \ / ___// //_// ___/ 50 | / /| // __// /_ | |/ |/ // /_/ // / / ,< (__ ) 51 | /_/ |_/ \___/ \__/ |__/|__/ \____//_/ /_/|_|/____/ ` 52 | 53 | var VolumesText = ` 54 | _ __ __ 55 | | | / /____ / /__ __ ____ ___ ___ _____ 56 | | | / // __ \ / // / / // __ '__ \ / _ \ / ___/ 57 | | |/ // /_/ // // /_/ // / / / / // __/(__ ) 58 | |___/ \____//_/ \__,_//_/ /_/ /_/ \___//____/ ` 59 | 60 | var NavText = ` 61 | _ __ 62 | / | / /____ _ _ __ 63 | / |/ // __ '/| | / / 64 | / /| // /_/ / | |/ / 65 | /_/ |_/ \__,_/ |___/ ` 66 | 67 | func TextStyle() lipgloss.Style { 68 | return lipgloss.NewStyle().Foreground(colors.Load().Text) 69 | } 70 | -------------------------------------------------------------------------------- /internal/docker/images.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/NucleoFusion/cruise/internal/utils" 9 | "github.com/docker/docker/api/types/filters" 10 | "github.com/docker/docker/api/types/image" 11 | ) 12 | 13 | func GetNumImages() int { 14 | images, err := cli.ImageList(context.Background(), image.ListOptions{}) 15 | if err != nil { 16 | fmt.Println("Error: " + err.Error()) 17 | return -1 18 | } 19 | 20 | return len(images) 21 | } 22 | 23 | func GetImages() ([]image.Summary, error) { 24 | return cli.ImageList(context.Background(), image.ListOptions{All: true}) 25 | } 26 | 27 | func RemoveImage(id string) error { 28 | _, err := cli.ImageRemove(context.Background(), id, image.RemoveOptions{PruneChildren: true, Force: false}) 29 | return err 30 | } 31 | 32 | func PruneImages() error { 33 | _, err := cli.ImagesPrune(context.Background(), filters.NewArgs()) 34 | return err 35 | } 36 | 37 | func PushImage(img string) error { 38 | _, err := cli.ImagePush(context.Background(), img, image.PushOptions{}) 39 | return err 40 | } 41 | 42 | func PullImage(img string) error { 43 | _, err := cli.ImagePull(context.Background(), img, image.PullOptions{}) 44 | return err 45 | } 46 | 47 | // TODO: Requires a seperate popup 48 | // func BuildImage(img string) error { 49 | // _, err := cli.ImageBuild(context.Background(), img, image.PullOptions{}) 50 | // return err 51 | // } 52 | 53 | func ImageHistory(img string) (string, error) { 54 | layers, err := cli.ImageHistory(context.Background(), img) 55 | if err != nil { 56 | return "", err 57 | } 58 | 59 | text := "" 60 | for k, v := range layers { 61 | id := v.ID 62 | if len(id) > 19 { 63 | id = id[:19] 64 | } 65 | text += fmt.Sprintf("Layer: %-3d %-20s Size: %-7s %s \n", k, id, fmt.Sprintf("%dMB", v.Size/(1024*1024)), v.CreatedBy) 66 | } 67 | 68 | return text, nil 69 | } 70 | 71 | func ImagesHeaders(width int) string { 72 | format := strings.Repeat(fmt.Sprintf("%%-%ds ", width), 5) 73 | 74 | return fmt.Sprintf( 75 | format, 76 | "ID", 77 | "RepoTags", 78 | "Size", 79 | "Created", 80 | "Containers", 81 | ) 82 | } 83 | 84 | func ImagesFormattedSummary(image image.Summary, width int) string { 85 | name := ":" 86 | if len(image.RepoTags) > 0 { 87 | name = image.RepoTags[0] 88 | } 89 | 90 | id := utils.Shorten(strings.TrimPrefix(image.ID, "sha256:"), 20) 91 | size := utils.FormatSize(image.Size) 92 | created := utils.CreatedAgo(image.Created) 93 | containers := fmt.Sprintf("%d", image.Containers) 94 | 95 | format := strings.Repeat(fmt.Sprintf("%%-%ds ", width), 5) 96 | 97 | return fmt.Sprintf( 98 | format, 99 | id, 100 | utils.Shorten(name, width), 101 | size, 102 | created, 103 | containers, 104 | ) 105 | } 106 | -------------------------------------------------------------------------------- /internal/docker/daemon.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "fmt" 7 | "os" 8 | "os/exec" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | type DaemonInfo struct { 14 | Version string 15 | OS string 16 | Uptime string 17 | } 18 | 19 | func GetDaemonInfo() (*DaemonInfo, error) { 20 | version, err := cli.ServerVersion(context.Background()) 21 | if err != nil { 22 | return &DaemonInfo{}, err 23 | } 24 | 25 | uptimeStart, err := GetDockerDaemonStartTime() 26 | if err != nil { 27 | return &DaemonInfo{}, err 28 | } 29 | 30 | uptime := formatUptime(uptimeStart) 31 | 32 | osname, err := GetOSName() 33 | if err != nil { 34 | return &DaemonInfo{}, err 35 | } 36 | 37 | return &DaemonInfo{ 38 | Version: version.Version, 39 | OS: osname, 40 | Uptime: uptime, 41 | }, nil 42 | } 43 | 44 | func formatUptime(startedAt time.Time) string { 45 | uptime := time.Since(startedAt) 46 | 47 | days := int(uptime.Hours()) / 24 48 | hours := int(uptime.Hours()) % 24 49 | minutes := int(uptime.Minutes()) % 60 50 | 51 | return fmt.Sprintf("%dd %dh %dm", days, hours, minutes) 52 | } 53 | 54 | func GetDockerDaemonStartTime() (time.Time, error) { 55 | // Run systemctl show to get ExecMainStartTimestamp property 56 | out, err := exec.Command("systemctl", "show", "-p", "ExecMainStartTimestamp", "docker.service").Output() 57 | if err != nil { 58 | return time.Time{}, fmt.Errorf("failed to run systemctl: %w", err) 59 | } 60 | line := strings.TrimSpace(string(out)) 61 | // Expected output format: ExecMainStartTimestamp=Mon 2025-07-21 07:03:37 UTC 62 | parts := strings.SplitN(line, "=", 2) 63 | if len(parts) != 2 { 64 | return time.Time{}, fmt.Errorf("unexpected output: %s", line) 65 | } 66 | timestampStr := parts[1] 67 | 68 | // Parse the timestamp. Format is "Mon 2006-01-02 15:04:05 MST" 69 | parsedTime, err := time.Parse("Mon 2006-01-02 15:04:05 MST", timestampStr) 70 | if err != nil { 71 | return time.Time{}, fmt.Errorf("failed to parse time: %w", err) 72 | } 73 | 74 | return parsedTime, nil 75 | } 76 | 77 | func GetOSName() (string, error) { 78 | file, err := os.Open("/etc/os-release") 79 | if err != nil { 80 | return "", fmt.Errorf("failed to open /etc/os-release: %w", err) 81 | } 82 | defer file.Close() 83 | 84 | scanner := bufio.NewScanner(file) 85 | for scanner.Scan() { 86 | line := scanner.Text() 87 | // Skip comments and unrelated lines 88 | if strings.HasPrefix(line, "#") || !strings.Contains(line, "=") { 89 | continue 90 | } 91 | parts := strings.SplitN(line, "=", 2) 92 | key := parts[0] 93 | value := parts[1] 94 | 95 | if key == "PRETTY_NAME" { 96 | // Remove surrounding quotes if any 97 | value = strings.Trim(value, `"`) 98 | return value, nil 99 | } 100 | } 101 | if err := scanner.Err(); err != nil { 102 | return "", fmt.Errorf("error reading /etc/os-release: %w", err) 103 | } 104 | return "", fmt.Errorf("PRETTY_NAME not found in /etc/os-release") 105 | } 106 | -------------------------------------------------------------------------------- /internal/docker/events.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | "time" 10 | 11 | "github.com/NucleoFusion/cruise/internal/config" 12 | "github.com/NucleoFusion/cruise/internal/utils" 13 | "github.com/docker/docker/api/types/events" 14 | "github.com/docker/docker/api/types/filters" 15 | ) 16 | 17 | func RecentEventStream(limit int) (<-chan *events.Message, <-chan error) { 18 | return GetEventStream(limit, filters.NewArgs()) 19 | } 20 | 21 | func GetEventStream(limit int, fltr filters.Args) (<-chan *events.Message, <-chan error) { 22 | eventChan := make(chan *events.Message) 23 | errChan := make(chan error) 24 | 25 | go func() { 26 | msgs, errs := cli.Events(context.Background(), events.ListOptions{Filters: fltr, Since: "10m"}) 27 | 28 | for { 29 | select { 30 | case msg := <-msgs: 31 | eventChan <- &msg 32 | case err := <-errs: 33 | errChan <- err 34 | } 35 | } 36 | }() 37 | 38 | return eventChan, errChan 39 | } 40 | 41 | func FormatDockerEvent(msg events.Message) string { 42 | eventTime := time.Unix(msg.Time, 0).Format("15:04:05") // only HH:MM:SS 43 | 44 | // Pick only relevant attributes 45 | keys := []string{"name", "image", "status"} 46 | attrs := "" 47 | for _, key := range keys { 48 | if val, ok := msg.Actor.Attributes[key]; ok { 49 | attrs += fmt.Sprintf("%s=%s ", key, val) 50 | } 51 | } 52 | if len(attrs) > 0 { 53 | attrs = attrs[:len(attrs)-1] // trim 54 | } 55 | 56 | return fmt.Sprintf("[%s] %s %s %s", eventTime, msg.Type, msg.Action, attrs) 57 | } 58 | 59 | func FormatDockerEventVerbose(msg events.Message) string { 60 | eventTime := time.Unix(msg.Time, 0).Format("15:04:05") 61 | 62 | // useful attr 63 | var keys []string 64 | switch msg.Type { 65 | case "container": 66 | keys = []string{"name", "image", "exitCode", "signal"} 67 | case "image": 68 | keys = []string{"name", "tag"} 69 | case "network": 70 | keys = []string{"name", "type"} 71 | case "volume": 72 | keys = []string{"name", "driver"} 73 | case "plugin": 74 | keys = []string{"name", "type"} 75 | default: 76 | keys = []string{} // fallback 77 | } 78 | 79 | var extras []string 80 | for _, k := range keys { 81 | if v, ok := msg.Actor.Attributes[k]; ok && v != "" { 82 | extras = append(extras, fmt.Sprintf("%s=%s", k, v)) 83 | } 84 | } 85 | 86 | return fmt.Sprintf( 87 | "[%s] %-20s %-10s %-10s %s", 88 | eventTime, 89 | utils.Shorten(msg.Actor.ID, 20), 90 | msg.Action, 91 | msg.Type, 92 | strings.Join(extras, ", "), 93 | ) 94 | } 95 | 96 | func Export(content []string, page string) error { 97 | filename := fmt.Sprintf("%d:%d_%d-%d_%s", time.Now().Hour(), time.Now().Minute(), time.Now().Day(), time.Now().Month(), page) 98 | 99 | f, err := os.Create(filepath.Join(config.Cfg.Global.ExportDir, filename)) 100 | if err != nil { 101 | return err 102 | } 103 | 104 | f.WriteString(strings.Join(content, "\n")) 105 | 106 | return nil 107 | } 108 | -------------------------------------------------------------------------------- /internal/models/home/logs.go: -------------------------------------------------------------------------------- 1 | package home 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/NucleoFusion/cruise/internal/docker" 7 | "github.com/NucleoFusion/cruise/internal/messages" 8 | "github.com/NucleoFusion/cruise/internal/styles" 9 | "github.com/NucleoFusion/cruise/internal/utils" 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/charmbracelet/lipgloss" 12 | "github.com/docker/docker/api/types/events" 13 | ) 14 | 15 | type Logs struct { 16 | Width int 17 | Height int 18 | Events []*events.Message 19 | EventChan <-chan *events.Message 20 | ErrChan <-chan error 21 | IsLoading bool 22 | Length int 23 | } 24 | 25 | func NewLogs(w int, h int) *Logs { 26 | eventChan, errChan := docker.RecentEventStream(h - 6) 27 | return &Logs{ 28 | Width: w, 29 | Height: h, 30 | IsLoading: true, 31 | Length: h - 6, 32 | EventChan: eventChan, 33 | ErrChan: errChan, 34 | } 35 | } 36 | 37 | func (s *Logs) Init() tea.Cmd { 38 | return tea.Batch(s.Sub()) 39 | } 40 | 41 | func (s *Logs) Sub() tea.Cmd { 42 | return tea.Every(2*time.Second, func(_ time.Time) tea.Msg { 43 | return s.PollEvents()() 44 | }) 45 | } 46 | 47 | func (s *Logs) Update(msg tea.Msg) (*Logs, tea.Cmd) { 48 | switch msg := msg.(type) { 49 | case messages.NewEvents: 50 | s.Events = append(s.Events, msg.Events...) 51 | 52 | if len(s.Events) > s.Length { 53 | s.Events = s.Events[len(s.Events)-s.Length:] 54 | } 55 | if s.IsLoading { 56 | s.IsLoading = false 57 | } 58 | 59 | return s, s.Sub() 60 | } 61 | return s, nil 62 | } 63 | 64 | func (s Logs) View() string { 65 | return styles.SubpageStyle().PaddingTop(1).PaddingLeft(4).Render(lipgloss.JoinVertical(lipgloss.Center, 66 | styles.TitleStyle().Render("Event Logs"), 67 | lipgloss.NewStyle(). 68 | Width(s.Width-6). //-6 from padding(4) and border(2) 69 | Height(s.Height-4). //-4 from title(1) border(2) and padding(1) 70 | Align(lipgloss.Left, lipgloss.Center). 71 | Render(s.FormattedView()))) 72 | } 73 | 74 | func (s *Logs) FormattedView() string { 75 | if s.IsLoading { 76 | return "Loading Logs....\n" 77 | } 78 | 79 | if len(s.Events) == 0 { 80 | return "No Logs yet, waiting....\n" 81 | } 82 | 83 | text := "" 84 | events := s.Events 85 | for _, msg := range events { 86 | text += docker.FormatDockerEvent(*msg) + "\n" 87 | } 88 | 89 | return text 90 | } 91 | 92 | func (s *Logs) PollEvents() tea.Cmd { 93 | return func() tea.Msg { 94 | evs := make([]*events.Message, 0, s.Length) 95 | 96 | select { 97 | case err := <-s.ErrChan: 98 | return utils.ReturnError("Home Page", "Error in Logs", err) 99 | default: 100 | for i := 0; i < s.Length; i++ { 101 | select { 102 | case ev := <-s.EventChan: 103 | evs = append(evs, ev) 104 | default: 105 | return messages.NewEvents{ 106 | Events: evs, 107 | } 108 | } 109 | } 110 | 111 | return messages.NewEvents{ 112 | Events: evs, 113 | } 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /docs/docs/config/styles.md: -------------------------------------------------------------------------------- 1 | # Styles 2 | Denoted by `[styles]` toml tag. 3 | 4 | ## Text 5 | Sets the primary text color used throughout the interface. Can be set using the `text` key. 6 | 7 | Default Value: 8 | ``` toml 9 | text = "#cdd6f4" 10 | ``` 11 | 12 | ## Sub Title Text 13 | Sets the color for subtitle text elements. Can be set using the `subtitle_text` key. 14 | 15 | Default Value: 16 | ``` toml 17 | subtitle_text = "#74c7ec" 18 | ``` 19 | 20 | ## Subtitle Background 21 | Sets the background color for subtitle sections. Can be set using the `subtitle_bg` key. 22 | 23 | Default Value: 24 | ``` toml 25 | subtitle_bg = "#313244" 26 | ``` 27 | 28 | ## Unfocused Border 29 | Sets the border color for unfocused UI elements. Can be set using the `unfocused_border` key. 30 | 31 | Default Value: 32 | ``` toml 33 | unfocused_border = "#45475a" 34 | ``` 35 | 36 | ## Focused Border 37 | Sets the border color for focused UI elements. Can be set using the `focused_border` key. 38 | 39 | Default Value: 40 | ``` toml 41 | focused_border = "#b4befe" 42 | ``` 43 | 44 | ## Help Key Background 45 | Sets the background color for help key indicators. Can be set using the `help_key_bg` key. 46 | 47 | Default Value: 48 | ``` toml 49 | help_key_bg = "#313244" 50 | ``` 51 | 52 | ## Help Key Text 53 | Sets the text color for help key indicators. Can be set using the `help_key_text` key. 54 | 55 | Default Value: 56 | ``` toml 57 | help_key_text = "#1e1e2e" 58 | ``` 59 | 60 | ## Help Descriptions Text 61 | Sets the text color for help descriptions. Can be set using the `help_desc_text` key. 62 | 63 | Default Value: 64 | ``` toml 65 | help_desc_text = "#6c7086" 66 | ``` 67 | 68 | ## Menu Selected Background 69 | Sets the background color for selected menu items. Can be set using the `menu_selected_bg` key. 70 | 71 | Default Value: 72 | ``` toml 73 | menu_selected_bg = "#b4befe" 74 | ``` 75 | 76 | ## Menu Selected Text 77 | Sets the text color for selected menu items. Can be set using the `menu_selected_text` key. 78 | 79 | Default Value: 80 | ``` toml 81 | menu_selected_text = "#1e1e2e" 82 | ``` 83 | 84 | ## Error Text 85 | Sets the text color for error messages. Can be set using the `error_text` key. 86 | 87 | Default Value: 88 | ``` toml 89 | error_text = "#f38ba8" 90 | ``` 91 | 92 | ## Error Background 93 | Sets the background color for error messages. Can be set using the `error_bg` key. 94 | 95 | Default Value: 96 | ``` toml 97 | error_bg = "#11111b" 98 | ``` 99 | 100 | ## Popup Border 101 | Sets the border color for popup windows and dialogs. Can be set using the `popup_border` key. 102 | 103 | Default Value: 104 | ``` toml 105 | popup_border = "#74c7ec" 106 | ``` 107 | 108 | ## Placeholder Text 109 | Sets the color for placeholder text in input fields. Can be set using the `placeholder_text` key. 110 | 111 | Default Value: 112 | ``` toml 113 | placeholder_text = "#585b70" 114 | ``` 115 | 116 | ## Message Text 117 | Sets the color for general message and notification text. Can be set using the `msg_text` key. 118 | 119 | Default Value: 120 | ``` toml 121 | msg_text = "#74c7ec" 122 | ``` 123 | 124 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sensible defaults. 2 | # Make sure to check the documentation at https://goreleaser.com 3 | 4 | # The lines below are called `modelines`. See `:help modeline` 5 | # Feel free to remove those if you don't want/need to use them. 6 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 7 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 8 | --- 9 | version: 2 10 | 11 | project_name: cruise 12 | before: 13 | hooks: 14 | # You may remove this if you don't use go modules. 15 | - go mod tidy 16 | 17 | # Normal tar.gz's 18 | builds: 19 | - binary: cruise 20 | env: 21 | - CGO_ENABLED=0 22 | goos: 23 | - linux 24 | - windows 25 | - darwin 26 | - freebsd 27 | goarch: 28 | - amd64 29 | - arm 30 | - arm64 31 | 32 | # deb/rpm files 33 | nfpms: 34 | - package_name: cruise 35 | homepage: https://nucleofusion.github.io/cruise/ 36 | maintainer: NucleoFusion 37 | description: |- 38 | Cruise is a powerful, intuitive, and fully-featured TUI (Terminal 39 | User Interface) for interacting with Docker 40 | license: MIT License 41 | formats: 42 | - rpm 43 | - deb 44 | 45 | # Homebrew 46 | homebrew_casks: 47 | - name: cruise 48 | binary: cruise 49 | url: 50 | template: "https://github.com/NucleoFusion/cruise/releases/download/{{ .Tag }}/{{ .ArtifactName }}" 51 | verified: "github.com/NucleoFusion/cruise" 52 | homepage: https://nucleofusion.github.io/cruise/ 53 | description: |- 54 | Cruise is a powerful, intuitive, and fully-featured TUI (Terminal 55 | User Interface) for interacting with Docker 56 | skip_upload: false 57 | directory: Casks 58 | repository: 59 | owner: NucleoFusion 60 | name: homebrew-tap 61 | branch: main 62 | 63 | # AUR 64 | aurs: 65 | - name: cruise-bin 66 | homepage: https://nucleofusion.github.io/cruise/ 67 | maintainers: 68 | - "NucleoFusion " 69 | description: "Cruise is a powerful, intuitive, and fully-featured TUI (Terminal User Interface) for interacting with Docker" 70 | license: "MIT" 71 | private_key: "{{ .Env.AUR_KEY }}" 72 | git_url: "ssh://aur@aur.archlinux.org/cruise-bin.git" 73 | optdepends: 74 | - trivy 75 | - grype 76 | package: |- 77 | install -Dm755 "./cruise" "${pkgdir}/usr/bin/cruise" 78 | 79 | archives: 80 | - formats: [tar.gz] 81 | # this name template makes the OS and Arch compatible 82 | # with the results of `uname`. 83 | name_template: >- 84 | {{ .ProjectName }}_ 85 | {{- title .Os }}_ 86 | {{- if eq .Arch "amd64" }}x86_64 87 | {{- else if eq .Arch "386" }}i386 88 | {{- else }}{{ .Arch }}{{ end }} 89 | {{- if .Arm }}v{{ .Arm }}{{ end }} 90 | # use zip for windows archives 91 | format_overrides: 92 | - goos: windows 93 | formats: [zip] 94 | 95 | changelog: 96 | sort: asc 97 | filters: 98 | exclude: 99 | - "^docs:" 100 | - "^test:" 101 | 102 | release: 103 | footer: >- 104 | 105 | --- 106 | 107 | Released by [GoReleaser](https://github.com/goreleaser/goreleaser). 108 | -------------------------------------------------------------------------------- /ROADMAP.md: -------------------------------------------------------------------------------- 1 | # Roadmap 2 | 3 | ## Project Vision 4 | 5 | Cruise is a terminal-first, extensible container management tool designed to empower developers and operators with unified, real-time control and insight over container workflows. 6 | 7 | We aim to bridge the gaps left by standard CLIs and GUIs, Cruise democratizes cloud-native and local container operations for everyone, anywhere. By breaking the “one CLI at a time” barrier by enabling powerful batch operations, visual dashboards, actionable monitoring, and seamless remote management creating a perfect management tool. 8 | 9 | ## Guiding Principles 10 | 11 | - **Terminal-First Accessibility:** 12 | Deliver powerful management and monitoring from within any terminal, empowering a wide range of users. 13 | 14 | - **Unified Control Surface:** 15 | Aggregate lifecycle, monitoring, and troubleshooting of containers, images, networks, and volumes in a single TUI. 16 | 17 | - **Real-Time Visibility:** 18 | Provide live dashboards, resource usage, and log insights to reduce manual polling and speed troubleshooting. 19 | 20 | - **Workflow Acceleration:** 21 | Enable batch operations, macros, and dashboard-driven actions to reduce repetitive CLI work. 22 | 23 | - **Extensibility and Pluggability:** 24 | Support plugins and multiple container runtimes—adapting to future community needs and environments. 25 | 26 | - **Observability and Diagnostics:** 27 | Aggregate logs and metrics with rich filtering and export to speed up problem solving. 28 | 29 | - **Low-Footprint, Easy Adoption:** 30 | Run with minimal dependencies and configuration for instant use anywhere. 31 | 32 | - **Open Collaboration:** 33 | Community-driven roadmap, transparent governance, and feedback loops as core values. 34 | 35 | - **Simplicity for Empowerment:** 36 | Lower onboarding friction for beginners while offering depth and efficiency for experts. 37 | 38 | 39 | ## Features 40 | 41 | ### Ongoing 42 | 43 | #### Images 44 | - [ ] Image repository browser 45 | 46 | #### Docker Compose (v2) 47 | 48 | ##### Dashboard 49 | - [ ] List all Compose projects 50 | - [ ] Up/down/restart/recreate operations 51 | - [ ] Visualize Compose service dependencies 52 | - [ ] Manage environment variables 53 | 54 | ##### Service Dashboard 55 | - [ ] Start/stop/restart/scale services 56 | - [ ] Real-time service monitoring 57 | - [ ] Network visualization 58 | - [ ] Aggregated service logs with filters 59 | 60 | ##### Compose Editor 61 | - [ ] Built-in editor with nvim or fallback 62 | - [ ] Syntax highlighting and error detection 63 | - [ ] Git integration for version control 64 | 65 | #### Build & Registry 66 | - [ ] Manage build contexts 67 | - [ ] Edit Dockerfiles with syntax support 68 | - [ ] Configure private registries 69 | - [ ] Manage and clean build cache 70 | 71 | #### Monitoring & Logs 72 | - [ ] Configure alerts and notifications 73 | - [ ] Export metrics and logs 74 | 75 | ### Future Plans 76 | 77 | - [ ] Remote/multi-host management (TUI across sockets/SSH) 78 | - [ ] Snapshotting and audit history 79 | - [ ] Workflow macros/automation 80 | - [ ] Additional container engines support (Podman, containerd, ...) 81 | 82 | ## Get Involved! 83 | 84 | Cruise is community-driven! Suggest features, propose improvements, or join our discussions to shape the future of container management from the terminal. 85 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: home 3 | pageClass: home-page 4 | 5 | hero: 6 | name: Cruise 7 | text: A Docker TUI Client 8 | actions: 9 | - theme: brand 10 | text: Getting Started 11 | link: /docs/ 12 | - theme: alt 13 | text: Documentation 14 | link: /docs/config/ 15 | 16 | features: 17 | - title: Comprehensive Docker Management 18 | details: Manage containers, images, volumes, and networks from a single TUI, covering the full lifecycle of Docker artifacts. 19 | - title: Real-Time Monitoring & Logs 20 | details: Get instant visibility into container health, system metrics, and live log streams, all in one dashboard. 21 | - title: Intuitive & Customizable UI 22 | details: A clean terminal interface with keyboard shortcuts, themes, and filters for a smooth developer experience. 23 | - title: Powerful Search & Filtering 24 | details: Quickly locate containers, images, or volumes with flexible search, sorting, and context-aware actions. 25 | --- 26 | 27 | 100 | -------------------------------------------------------------------------------- /docs/about/index.md: -------------------------------------------------------------------------------- 1 | # About 2 | 3 | Cruise is a powerful, intuitive, and fully-featured TUI (Terminal User Interface) for interacting with Docker. Built with Go and Bubbletea. 4 | It offers a visually rich, keyboard-first experience for managing containers, images, volumes, networks, logs and more — all from your terminal. 5 | 6 |
7 | Screenshots 8 | 9 | ![screenshot](/1.png) 10 | ![screenshot](/2.png) 11 | ![screenshot](/3.png) 12 | ![screenshot](/4.png) 13 | ![screenshot](/5.png) 14 | ![screenshot](/6.png) 15 | ![screenshot](/7.png) 16 | ![screenshot](/8.png) 17 | ![screenshot](/9.png) 18 | ![screenshot](/10.png) 19 | ![screenshot](/11.png) 20 | 21 |
22 | 23 | ## Features 24 | 25 | - Manage Lifecycles of Docker Artifacts, currently supports, 26 | 1. Containers 27 | 2. Images 28 | 3. Networks 29 | 4. Volumes 30 | - Aggregated Detailed statistics of Docker Artifacts 31 | - Aggregated Monitoring and Logging service 32 | - Scanning Images for Vulnerabilities 33 | - ...and more to come 34 | 35 |
36 | 37 | #### Planned 38 | 39 | - Docker Compose Management 40 | - More Vulnerability Scanners 41 | - Docker Registry management 42 | - Image repository browser 43 | - Alert integration for Monitoring 44 | 45 | ## Why not Docker CLI? 46 | 47 | > Ever found yourself writing commands again and again for stats? or wrote a long command just for a typo to mess it up? 48 | 49 | Cruise solves this problem, now you can interact with docker from your terminal, from common lifecycle management to complex 50 | Vulnerability scanning. 51 | 52 | Cruise seemlessly fits into your terminal-first dev workflow, be that on a remote server or a physical machine. Making working with docker easier 53 | and more fun. 54 | 55 | ## Why not other TUI Alternatives? 56 | 57 | Other TUI Alternatives such as lazydocker or docui (deprecated) are more of a _monitoring_ service than a _management_ application. 58 | 59 | Cruise on the other hand, aims to be a Docker TUI Client, allowing management _and_ monitoring of most docker services. With cruise 60 | you can have the benefits of a TUI app without having to deal with the downsides of the Docker CLI. It blends both of them perfectly 61 | obtaining the best of both worlds. 62 | 63 | Oh, and did I mention? Cruise looks a lot better... _and is also configurable_. 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /internal/models/vulnerability/scan.go: -------------------------------------------------------------------------------- 1 | package vulnerability 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/NucleoFusion/cruise/internal/colors" 8 | "github.com/NucleoFusion/cruise/internal/config" 9 | "github.com/NucleoFusion/cruise/internal/docker" 10 | "github.com/NucleoFusion/cruise/internal/messages" 11 | "github.com/NucleoFusion/cruise/internal/styles" 12 | "github.com/charmbracelet/bubbles/viewport" 13 | tea "github.com/charmbracelet/bubbletea" 14 | "github.com/charmbracelet/lipgloss" 15 | ) 16 | 17 | type ScanList struct { 18 | Width int 19 | Height int 20 | Vp viewport.Model 21 | Scanners []string 22 | Found []bool 23 | Focused bool 24 | SelectedIndex int 25 | IsLoading bool 26 | } 27 | 28 | func NewScanList(w int, h int) *ScanList { 29 | vp := viewport.New(w, h) 30 | vp.Style = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(colors.Load().FocusedBorder). 31 | Padding(1).Foreground(colors.Load().Text) 32 | 33 | return &ScanList{ 34 | Width: w, 35 | Height: h, 36 | Vp: vp, 37 | Scanners: []string{"trivy", "grype"}, 38 | IsLoading: true, 39 | } 40 | } 41 | 42 | func (s *ScanList) Init() tea.Cmd { 43 | return tea.Tick(0, func(_ time.Time) tea.Msg { 44 | arr := make([]bool, 0, len(s.Scanners)) 45 | for _, v := range s.Scanners { 46 | arr = append(arr, docker.ScannerAvailable(v)) 47 | } 48 | 49 | return messages.ScannerListMsg{ 50 | Found: arr, 51 | } 52 | }) 53 | } 54 | 55 | func (s *ScanList) Update(msg tea.Msg) (*ScanList, tea.Cmd) { 56 | switch msg := msg.(type) { 57 | case messages.ScannerListMsg: 58 | s.Found = msg.Found 59 | s.IsLoading = false 60 | return s, nil 61 | case tea.KeyMsg: 62 | switch msg.String() { 63 | case config.Cfg.Keybinds.Global.ListDown: 64 | if s.SelectedIndex < len(s.Scanners)-1 { 65 | s.SelectedIndex++ 66 | } 67 | return s, nil 68 | case config.Cfg.Keybinds.Global.ListUp: 69 | if s.SelectedIndex > 0 { 70 | s.SelectedIndex-- 71 | } 72 | return s, nil 73 | } 74 | } 75 | return s, nil 76 | } 77 | 78 | func (s *ScanList) View() string { 79 | s.Vp.Style = s.Vp.Style.BorderForeground(colors.Load().UnfocusedBorder) 80 | if s.Focused { 81 | s.Vp.Style = s.Vp.Style.BorderForeground(colors.Load().FocusedBorder) 82 | } 83 | 84 | if s.IsLoading { 85 | return styles.PageStyle().BorderForeground(colors.Load().UnfocusedBorder).Render(lipgloss.Place(s.Width-2, s.Height-2, lipgloss.Center, lipgloss.Top, "Loading...")) 86 | } 87 | 88 | s.UpdateText() 89 | 90 | return lipgloss.Place(s.Width, s.Height, lipgloss.Center, lipgloss.Top, s.Vp.View()) 91 | } 92 | 93 | func (s *ScanList) UpdateText() { 94 | text := lipgloss.PlaceHorizontal(s.Width-2, lipgloss.Center, styles.TitleStyle().Render("Supported Scanners")) + "\n\n\n" 95 | for k, v := range s.Scanners { 96 | line := v 97 | if !s.Found[k] { 98 | line = fmt.Sprintf("%s - %s", v, styles.ErrorStyle().Render("Not Found")) 99 | } 100 | 101 | if s.SelectedIndex == k { 102 | line = styles.SelectedStyle().Width(s.Width - 2).Render(line) 103 | } else { 104 | line = lipgloss.NewStyle().Width(s.Width - 2).Render(line) 105 | } 106 | 107 | text += line + "\n" 108 | } 109 | 110 | s.Vp.SetContent(text) 111 | } 112 | 113 | func (s *ScanList) GetSelected() (string, bool) { 114 | return s.Scanners[s.SelectedIndex], s.Found[s.SelectedIndex] 115 | } 116 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/NucleoFusion/cruise 2 | 3 | go 1.24.3 4 | 5 | require ( 6 | github.com/charmbracelet/bubbles v0.21.0 7 | github.com/charmbracelet/bubbletea v1.3.6 8 | github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 9 | github.com/docker/docker v28.3.3+incompatible 10 | github.com/lithammer/fuzzysearch v1.1.8 11 | github.com/pelletier/go-toml/v2 v2.2.4 12 | github.com/rmhubbert/bubbletea-overlay v0.4.0 13 | github.com/shirou/gopsutil/v3 v3.24.5 14 | github.com/spf13/cobra v1.9.1 15 | github.com/spf13/viper v1.20.1 16 | ) 17 | 18 | require ( 19 | github.com/Microsoft/go-winio v0.4.14 // indirect 20 | github.com/atotto/clipboard v0.1.4 // indirect 21 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 22 | github.com/charmbracelet/colorprofile v0.3.1 // indirect 23 | github.com/charmbracelet/x/ansi v0.9.3 // indirect 24 | github.com/charmbracelet/x/cellbuf v0.0.13 // indirect 25 | github.com/charmbracelet/x/term v0.2.1 // indirect 26 | github.com/containerd/errdefs v1.0.0 // indirect 27 | github.com/containerd/errdefs/pkg v0.3.0 // indirect 28 | github.com/containerd/log v0.1.0 // indirect 29 | github.com/distribution/reference v0.6.0 // indirect 30 | github.com/docker/go-connections v0.5.0 // indirect 31 | github.com/docker/go-units v0.5.0 // indirect 32 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 33 | github.com/felixge/httpsnoop v1.0.4 // indirect 34 | github.com/fsnotify/fsnotify v1.8.0 // indirect 35 | github.com/go-logr/logr v1.4.3 // indirect 36 | github.com/go-logr/stdr v1.2.2 // indirect 37 | github.com/go-ole/go-ole v1.2.6 // indirect 38 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 39 | github.com/gogo/protobuf v1.3.2 // indirect 40 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 41 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 42 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect 43 | github.com/mattn/go-isatty v0.0.20 // indirect 44 | github.com/mattn/go-localereader v0.0.1 // indirect 45 | github.com/mattn/go-runewidth v0.0.16 // indirect 46 | github.com/moby/docker-image-spec v1.3.1 // indirect 47 | github.com/moby/sys/atomicwriter v0.1.0 // indirect 48 | github.com/moby/term v0.5.2 // indirect 49 | github.com/morikuni/aec v1.0.0 // indirect 50 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 51 | github.com/muesli/cancelreader v0.2.2 // indirect 52 | github.com/muesli/termenv v0.16.0 // indirect 53 | github.com/opencontainers/go-digest v1.0.0 // indirect 54 | github.com/opencontainers/image-spec v1.1.1 // indirect 55 | github.com/pkg/errors v0.9.1 // indirect 56 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect 57 | github.com/rivo/uniseg v0.4.7 // indirect 58 | github.com/sagikazarmark/locafero v0.7.0 // indirect 59 | github.com/shoenig/go-m1cpu v0.1.6 // indirect 60 | github.com/sourcegraph/conc v0.3.0 // indirect 61 | github.com/spf13/afero v1.12.0 // indirect 62 | github.com/spf13/cast v1.7.1 // indirect 63 | github.com/spf13/pflag v1.0.7 // indirect 64 | github.com/subosito/gotenv v1.6.0 // indirect 65 | github.com/tklauser/go-sysconf v0.3.12 // indirect 66 | github.com/tklauser/numcpus v0.6.1 // indirect 67 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 68 | github.com/yusufpapurcu/wmi v1.2.4 // indirect 69 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 70 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect 71 | go.opentelemetry.io/otel v1.37.0 // indirect 72 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0 // indirect 73 | go.opentelemetry.io/otel/metric v1.37.0 // indirect 74 | go.opentelemetry.io/otel/sdk v1.37.0 // indirect 75 | go.opentelemetry.io/otel/trace v1.37.0 // indirect 76 | go.uber.org/atomic v1.9.0 // indirect 77 | go.uber.org/multierr v1.9.0 // indirect 78 | golang.org/x/sync v0.16.0 // indirect 79 | golang.org/x/sys v0.34.0 // indirect 80 | golang.org/x/text v0.27.0 // indirect 81 | gopkg.in/yaml.v3 v3.0.1 // indirect 82 | gotest.tools/v3 v3.5.2 // indirect 83 | ) 84 | -------------------------------------------------------------------------------- /internal/models/home/sys.go: -------------------------------------------------------------------------------- 1 | package home 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "strings" 7 | "time" 8 | 9 | "github.com/NucleoFusion/cruise/internal/data" 10 | "github.com/NucleoFusion/cruise/internal/messages" 11 | "github.com/NucleoFusion/cruise/internal/styles" 12 | tea "github.com/charmbracelet/bubbletea" 13 | "github.com/charmbracelet/lipgloss" 14 | ) 15 | 16 | type SysRes struct { 17 | Width int 18 | Height int 19 | IsLoading bool 20 | CPU *data.CPUInfo 21 | Mem *data.MemInfo 22 | Disk *data.DiskInfo 23 | } 24 | 25 | func NewSysRes(w int, h int) *SysRes { 26 | return &SysRes{ 27 | Width: w, 28 | Height: h, 29 | IsLoading: true, 30 | } 31 | } 32 | 33 | func (s *SysRes) Init() tea.Cmd { 34 | return tea.Tick(0, func(t time.Time) tea.Msg { 35 | cpuChan := make(chan *data.CPUInfo, 1) 36 | memChan := make(chan *data.MemInfo, 1) 37 | diskChan := make(chan *data.DiskInfo, 1) 38 | go func() { 39 | cpuChan <- data.GetCPUInfo() 40 | }() 41 | go func() { 42 | memChan <- data.GetMemInfo() 43 | }() 44 | go func() { 45 | diskChan <- data.GetDiskInfo() 46 | }() 47 | return messages.SysResReadyMsg{ 48 | CPU: <-cpuChan, 49 | Mem: <-memChan, 50 | Disk: <-diskChan, 51 | } 52 | }) 53 | } 54 | 55 | func (s *SysRes) Update(msg tea.Msg) (*SysRes, tea.Cmd) { 56 | switch msg := msg.(type) { 57 | case messages.SysResReadyMsg: 58 | s.IsLoading = false 59 | s.CPU = msg.CPU 60 | s.Mem = msg.Mem 61 | s.Disk = msg.Disk 62 | return s, nil 63 | } 64 | return s, nil 65 | } 66 | 67 | func (s *SysRes) View() string { 68 | return styles.SubpageStyle().PaddingTop(1).PaddingLeft(4).Render(lipgloss.JoinVertical(lipgloss.Center, 69 | styles.TitleStyle().Render("System Resources"), 70 | lipgloss.NewStyle(). 71 | Width(s.Width-6). //-6 from padding(4) and border(2) 72 | Height(s.Height-4). //-4 from title(1) border(2) and padding(1) 73 | Align(lipgloss.Left, lipgloss.Center). 74 | Render(s.FormattedView()))) 75 | } 76 | 77 | func (s SysRes) FormattedView() string { 78 | if s.IsLoading { 79 | return "Querying System Data..." 80 | } 81 | 82 | cputext := "" 83 | if s.CPU.Error != nil { 84 | cputext = fmt.Sprintf("ERROR: %s", s.CPU.Error.Error()) 85 | } else { 86 | cpufilled := int((s.CPU.Usage / 100) * float64(50)) 87 | cpubar := strings.Repeat("█", cpufilled) + strings.Repeat(" ", 50-cpufilled-1) 88 | cputext = fmt.Sprintf("CPU: [%s] %.1f%% | %.1fGhz - %dL/%dP Cores", cpubar, math.Round(s.CPU.Usage*10)/10, math.Round(s.CPU.Mhz/100)/10, 89 | s.CPU.LogicCores, s.CPU.PhysicalCores) 90 | } 91 | 92 | memtext := "" 93 | if s.Mem.Err != nil { 94 | memtext = fmt.Sprintf("ERROR: %s", s.Mem.Err.Error()) 95 | } else { 96 | memfilled := int((s.Mem.Usage / 100) * float64(50)) 97 | membar := strings.Repeat("█", memfilled) + strings.Repeat(" ", 50-memfilled-1) 98 | memtext = fmt.Sprintf("Mem: [%s] %.1f%% | %.1fGB / %.1fGB", membar, s.Mem.Usage, s.Mem.Used, s.Mem.Total) 99 | } 100 | 101 | disktext := "" 102 | if s.Disk.Err != nil { 103 | disktext = fmt.Sprintf("ERROR: %s", s.Mem.Err.Error()) 104 | } else { 105 | diskfilled := int((s.Disk.Usage / 100) * float64(50)) 106 | diskbar := strings.Repeat("█", diskfilled) + strings.Repeat(" ", 50-diskfilled-1) 107 | disktext = fmt.Sprintf("Disk: [%s] %.1f%% | %.1fGB / %.1fGB", diskbar, s.Disk.Usage, s.Disk.Used, s.Disk.Total) 108 | } 109 | 110 | return fmt.Sprintf("%s\n\n%s\n\n%s", cputext, memtext, disktext) 111 | } 112 | 113 | func (s *SysRes) Refresh() tea.Cmd { 114 | return tea.Tick(0, func(t time.Time) tea.Msg { 115 | cpuChan := make(chan *data.CPUInfo, 1) 116 | memChan := make(chan *data.MemInfo, 1) 117 | diskChan := make(chan *data.DiskInfo, 1) 118 | go func() { 119 | cpuChan <- data.GetCPUInfo() 120 | }() 121 | go func() { 122 | memChan <- data.GetMemInfo() 123 | }() 124 | go func() { 125 | diskChan <- data.GetDiskInfo() 126 | }() 127 | return messages.SysResReadyMsg{ 128 | CPU: <-cpuChan, 129 | Mem: <-memChan, 130 | Disk: <-diskChan, 131 | } 132 | }) 133 | } 134 | -------------------------------------------------------------------------------- /internal/models/volumes/volumes.go: -------------------------------------------------------------------------------- 1 | package volumes 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/NucleoFusion/cruise/internal/docker" 9 | "github.com/NucleoFusion/cruise/internal/keymap" 10 | "github.com/NucleoFusion/cruise/internal/messages" 11 | styledhelp "github.com/NucleoFusion/cruise/internal/models/help" 12 | "github.com/NucleoFusion/cruise/internal/styles" 13 | "github.com/NucleoFusion/cruise/internal/utils" 14 | "github.com/charmbracelet/bubbles/key" 15 | tea "github.com/charmbracelet/bubbletea" 16 | "github.com/charmbracelet/lipgloss" 17 | ) 18 | 19 | type Volumes struct { 20 | Width int 21 | Height int 22 | List *VolumeList 23 | Details *VolumeDetail 24 | Keymap keymap.VolMap 25 | Help styledhelp.StyledHelp 26 | IsLoading bool 27 | ShowDetail bool 28 | } 29 | 30 | func NewVolumes(w int, h int) *Volumes { 31 | return &Volumes{ 32 | Width: w, 33 | Height: h, 34 | IsLoading: true, 35 | ShowDetail: false, 36 | List: NewVolumeList(w-2, h-5-strings.Count(styles.VolumesText, "\n")), //h-5 to account for styled help and title padding 37 | Keymap: keymap.NewVolMap(), 38 | Help: styledhelp.NewStyledHelp(keymap.NewVolMap().Bindings(), w-2), 39 | } 40 | } 41 | 42 | func (s *Volumes) Init() tea.Cmd { 43 | return s.List.Init() 44 | } 45 | 46 | func (s *Volumes) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 47 | switch msg := msg.(type) { 48 | case messages.VolumesReadyMsg: 49 | s.IsLoading = false 50 | var cmd tea.Cmd 51 | s.List, cmd = s.List.Update(msg) 52 | return s, cmd 53 | case messages.CloseDetails: 54 | s.ShowDetail = false 55 | return s, nil 56 | case tea.KeyMsg: 57 | if s.List.Ti.Focused() { 58 | var cmd tea.Cmd 59 | s.List, cmd = s.List.Update(msg) 60 | return s, cmd 61 | } 62 | switch { 63 | case key.Matches(msg, keymap.QuickQuitKey()): 64 | return s, tea.Quit 65 | case key.Matches(msg, s.Keymap.Remove): 66 | err := docker.RemoveVolumes(s.List.GetCurrentItem().Name) 67 | if err != nil { 68 | return s, utils.ReturnError("Volumes Page", "Error Removing Volume", err) 69 | } 70 | return s, tea.Batch(s.Refresh(), utils.ReturnMsg("Volumes Page", "Removed Volume", 71 | fmt.Sprintf("Successfully Removed Volume %s", s.List.GetCurrentItem().Name))) 72 | case key.Matches(msg, s.Keymap.Prune): 73 | err := docker.PruneVolumes() 74 | if err != nil { 75 | return s, utils.ReturnError("Volumes Page", "Error Pruning Volumes", err) 76 | } 77 | return s, tea.Batch(s.Refresh(), utils.ReturnMsg("Volumes Page", "Pruned Volumes", 78 | "Successfully Pruned Volumes")) 79 | case key.Matches(msg, s.Keymap.ExitDetails): 80 | if s.ShowDetail { 81 | s.ShowDetail = false 82 | return s, nil 83 | } 84 | case key.Matches(msg, s.Keymap.ShowDetails): 85 | s.ShowDetail = true 86 | s.Details = NewDetail(s.Width, s.Height, s.List.GetCurrentItem()) 87 | return s, nil 88 | } 89 | } 90 | 91 | var cmd tea.Cmd 92 | s.List, cmd = s.List.Update(msg) 93 | return s, cmd 94 | } 95 | 96 | func (s *Volumes) View() string { 97 | if s.ShowDetail { 98 | return styles.SceneStyle().Render(s.Details.View()) 99 | } 100 | 101 | return styles.SceneStyle().Render( 102 | lipgloss.JoinVertical(lipgloss.Center, 103 | styles.TextStyle().Padding(1, 0).Render(styles.VolumesText), s.GetListText(), s.Help.View())) 104 | } 105 | 106 | func (s *Volumes) GetListText() string { 107 | if s.IsLoading { 108 | return lipgloss.Place(s.Width-2, s.Height-4-strings.Count(styles.VolumesText, "\n"), 109 | lipgloss.Center, lipgloss.Center, styles.TextStyle().Render("Loading...")) 110 | } 111 | 112 | return lipgloss.NewStyle().Render(s.List.View()) 113 | } 114 | 115 | func (s *Volumes) Refresh() tea.Cmd { 116 | return tea.Tick(0, func(_ time.Time) tea.Msg { 117 | vols, err := docker.GetVolumes() 118 | if err != nil { 119 | fmt.Println(err) 120 | return utils.ReturnError("Volumes Page", "Error Querying Volumes", err) 121 | } 122 | return messages.VolumesReadyMsg{Items: vols.Volumes} 123 | }) 124 | } 125 | -------------------------------------------------------------------------------- /internal/models/networks/networks.go: -------------------------------------------------------------------------------- 1 | package networks 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/NucleoFusion/cruise/internal/docker" 9 | "github.com/NucleoFusion/cruise/internal/keymap" 10 | "github.com/NucleoFusion/cruise/internal/messages" 11 | styledhelp "github.com/NucleoFusion/cruise/internal/models/help" 12 | "github.com/NucleoFusion/cruise/internal/styles" 13 | "github.com/NucleoFusion/cruise/internal/utils" 14 | "github.com/charmbracelet/bubbles/key" 15 | tea "github.com/charmbracelet/bubbletea" 16 | "github.com/charmbracelet/lipgloss" 17 | ) 18 | 19 | type Networks struct { 20 | Width int 21 | Height int 22 | List *NetworkList 23 | Details *NetworkDetail 24 | Keymap keymap.NetMap 25 | Help styledhelp.StyledHelp 26 | IsLoading bool 27 | ShowDetail bool 28 | } 29 | 30 | func NewNetworks(w int, h int) *Networks { 31 | return &Networks{ 32 | Width: w, 33 | Height: h, 34 | IsLoading: true, 35 | ShowDetail: false, 36 | List: NewNetworkList(w-2, h-5-strings.Count(styles.NetworksText, "\n")), //h-5 to account for styled help and title padding 37 | Keymap: keymap.NewNetMap(), 38 | Help: styledhelp.NewStyledHelp(keymap.NewNetMap().Bindings(), w-2), 39 | } 40 | } 41 | 42 | func (s *Networks) Init() tea.Cmd { 43 | return s.List.Init() 44 | } 45 | 46 | func (s *Networks) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 47 | switch msg := msg.(type) { 48 | case messages.NetworksReadyMsg: 49 | s.IsLoading = false 50 | 51 | var cmd tea.Cmd 52 | s.List, cmd = s.List.Update(msg) 53 | return s, cmd 54 | case messages.UpdateNetworksMsg: 55 | var cmd tea.Cmd 56 | s.List, cmd = s.List.Update(msg) 57 | return s, cmd 58 | case messages.CloseDetails: 59 | s.ShowDetail = false 60 | return s, nil 61 | case tea.KeyMsg: 62 | if s.List.Ti.Focused() { 63 | var cmd tea.Cmd 64 | s.List, cmd = s.List.Update(msg) 65 | return s, cmd 66 | } 67 | switch { 68 | case key.Matches(msg, keymap.QuickQuitKey()): 69 | return s, tea.Quit 70 | case key.Matches(msg, s.Keymap.ShowDetails): 71 | s.ShowDetail = true 72 | s.Details = NewDetail(s.Width, s.Height, s.List.GetCurrentItem()) 73 | return s, nil 74 | case key.Matches(msg, s.Keymap.ExitDetails): 75 | if s.ShowDetail { 76 | s.ShowDetail = false 77 | return s, nil 78 | } 79 | case key.Matches(msg, s.Keymap.Remove): 80 | err := docker.RemoveNetwork(s.List.GetCurrentItem().ID) 81 | if err != nil { 82 | return s, utils.ReturnError("Networks Page", "Error Removing Network", err) 83 | } 84 | return s, tea.Batch(s.Refresh(), utils.ReturnMsg("Networks Page", "Removed Network", 85 | fmt.Sprintf("Successfully Removed Networks w/ ID %s", s.List.GetCurrentItem().ID))) 86 | case key.Matches(msg, s.Keymap.Prune): 87 | err := docker.PruneNetworks() 88 | if err != nil { 89 | return s, utils.ReturnError("Networks Page", "Error Pruning Networks", err) 90 | } 91 | return s, tea.Batch(s.Refresh(), utils.ReturnMsg("Networks Page", "Pruned Networks", 92 | "Successfully Pruned Networks")) 93 | } 94 | } 95 | 96 | var cmd tea.Cmd 97 | s.List, cmd = s.List.Update(msg) 98 | return s, cmd 99 | } 100 | 101 | func (s *Networks) View() string { 102 | if s.ShowDetail { 103 | return styles.SceneStyle().Render(s.Details.View()) 104 | } 105 | 106 | return styles.SceneStyle().Render( 107 | lipgloss.JoinVertical(lipgloss.Center, 108 | styles.TextStyle().Padding(1, 0).Render(styles.NetworksText), s.GetListText(), s.Help.View())) 109 | } 110 | 111 | func (s *Networks) GetListText() string { 112 | if s.IsLoading { 113 | return lipgloss.Place(s.Width-2, s.Height-4-strings.Count(styles.NetworksText, "\n"), 114 | lipgloss.Center, lipgloss.Top, "Loading...") 115 | } 116 | 117 | return lipgloss.NewStyle().Render(s.List.View()) 118 | } 119 | 120 | func (s *Networks) Refresh() tea.Cmd { 121 | return tea.Tick(3*time.Second, func(_ time.Time) tea.Msg { 122 | items, err := docker.GetNetworks() 123 | if err != nil { 124 | return utils.ReturnError("Networks Page", "Error Querying Networks", err) 125 | } 126 | return messages.NetworksReadyMsg{Items: items} 127 | }) 128 | } 129 | -------------------------------------------------------------------------------- /internal/models/fzf/fzf.go: -------------------------------------------------------------------------------- 1 | package fzf 2 | 3 | import ( 4 | "sort" 5 | 6 | "github.com/NucleoFusion/cruise/internal/colors" 7 | "github.com/NucleoFusion/cruise/internal/keymap" 8 | "github.com/NucleoFusion/cruise/internal/messages" 9 | styledhelp "github.com/NucleoFusion/cruise/internal/models/help" 10 | "github.com/charmbracelet/bubbles/key" 11 | "github.com/charmbracelet/bubbles/textinput" 12 | "github.com/charmbracelet/bubbles/viewport" 13 | tea "github.com/charmbracelet/bubbletea" 14 | "github.com/charmbracelet/lipgloss" 15 | "github.com/lithammer/fuzzysearch/fuzzy" 16 | ) 17 | 18 | type FuzzyFinder struct { 19 | Width int 20 | Height int 21 | Keymap keymap.FuzzyMap 22 | Help styledhelp.StyledHelp 23 | Vp viewport.Model 24 | Ti textinput.Model 25 | Items []string 26 | Filtered []string 27 | SelectedIndex int 28 | } 29 | 30 | func NewFzf(items []string, w int, h int) FuzzyFinder { 31 | return FuzzyFinder{ 32 | Width: w, 33 | Height: h, 34 | Help: styledhelp.NewStyledHelp(keymap.NewFuzzyMap().Bindings(), w), 35 | Keymap: keymap.NewFuzzyMap(), 36 | Items: items, 37 | Filtered: items, 38 | Vp: NewVP(w, h, items), 39 | Ti: NewTI(w / 3), 40 | SelectedIndex: 0, 41 | } 42 | } 43 | 44 | func (m *FuzzyFinder) Init() tea.Cmd { 45 | return nil 46 | } 47 | 48 | func (m *FuzzyFinder) Update(msg tea.Msg) (FuzzyFinder, tea.Cmd) { 49 | switch msg := msg.(type) { 50 | case tea.KeyMsg: 51 | switch { 52 | case key.Matches(msg, m.Keymap.StartWriting): 53 | m.Ti.Placeholder = "Search..." 54 | m.Ti.Focus() 55 | case key.Matches(msg, m.Keymap.Down): 56 | if len(m.Filtered)-1 > m.SelectedIndex { 57 | m.SelectedIndex += 1 58 | } 59 | if m.SelectedIndex > m.Vp.Height+m.Vp.YOffset-3 { // -2 for border and something else, idk breaks otherwise 60 | m.Vp.YOffset += 1 61 | } 62 | m.UpdateVP() 63 | return *m, nil 64 | case key.Matches(msg, m.Keymap.Up): 65 | if 0 < m.SelectedIndex { 66 | m.SelectedIndex -= 1 67 | } 68 | if m.SelectedIndex < m.Vp.YOffset { 69 | m.Vp.YOffset -= 1 70 | } 71 | m.UpdateVP() 72 | return *m, nil 73 | case key.Matches(msg, m.Keymap.Enter): 74 | return *m, func() tea.Msg { 75 | return messages.FzfSelection{ 76 | Selection: m.Filtered[m.SelectedIndex], 77 | Exited: false, 78 | } 79 | } 80 | case key.Matches(msg, m.Keymap.Exit): 81 | return *m, func() tea.Msg { 82 | return messages.FzfSelection{ 83 | Selection: "", 84 | Exited: true, 85 | } 86 | } 87 | case key.Matches(msg, keymap.QuickQuitKey()): 88 | return *m, tea.Quit 89 | default: 90 | if m.Ti.Focused() { 91 | var cmd tea.Cmd 92 | m.Ti, cmd = m.Ti.Update(msg) 93 | m.Filter(m.Ti.Value()) 94 | m.UpdateVP() 95 | return *m, cmd 96 | } 97 | } 98 | } 99 | 100 | return *m, nil 101 | } 102 | 103 | func (m *FuzzyFinder) View() string { 104 | pg := lipgloss.Place(m.Width, m.Height-1, lipgloss.Center, lipgloss.Center, 105 | lipgloss.JoinVertical(lipgloss.Center, 106 | lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(colors.Load().FocusedBorder).Render(m.Ti.View()), 107 | m.Vp.View())) 108 | hlp := lipgloss.NewStyle().PaddingLeft(2).Render(m.Help.View()) 109 | return lipgloss.JoinVertical(lipgloss.Left, pg, hlp) 110 | } 111 | 112 | func (m *FuzzyFinder) UpdateVP() { 113 | text := "" 114 | for k, v := range m.Filtered { 115 | if k == m.SelectedIndex { 116 | text += SelectedItemStyle(m.Width/3).Render(v) + "\n" 117 | continue 118 | } 119 | 120 | text += ItemLineStyle(m.Width/3).Render(v) + "\n" 121 | } 122 | 123 | m.Vp.SetContent(text) 124 | } 125 | 126 | func (m *FuzzyFinder) Filter(val string) { 127 | ranked := fuzzy.RankFindFold(val, m.Items) 128 | sort.Sort(ranked) // Sort by ascending distance 129 | result := make([]string, len(ranked)) 130 | for i, r := range ranked { 131 | result[i] = r.Target 132 | } 133 | 134 | m.Filtered = result 135 | 136 | if len(m.Filtered) <= m.SelectedIndex { // So that index doesnt go out of bounds 137 | m.SelectedIndex = len(m.Filtered) - 1 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /internal/config/keyModels.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | type Keybinds struct { 4 | Global GlobalKeybinds `mapstructure:"global" toml:"global"` 5 | Nav NavKeybinds `mapstructure:"nav" toml:"nav"` 6 | Container ContainersKeybinds `mapstructure:"container" toml:"container"` 7 | Images ImagesKeybinds `mapstructure:"images" toml:"images"` 8 | Fzf FzfKeybinds `mapstructure:"fzf" toml:"fzf"` 9 | Monitoring MonitorKeybinds `mapstructure:"monitoring" toml:"monitoring"` 10 | Network NetworkKeybinds `mapstructure:"network" toml:"network"` 11 | Volumes VolumeKeybinds `mapstructure:"volume" toml:"volume"` 12 | Vulnerability VulnerabilityKeybinds `mapstructure:"vulnerability" toml:"vulnerability"` 13 | } 14 | 15 | type GlobalKeybinds struct { 16 | PageFinder string `mapstructure:"page_finder" toml:"page_finder"` 17 | ListUp string `mapstructure:"list_up" toml:"list_up"` 18 | ListDown string `mapstructure:"list_down" toml:"list_down"` 19 | FocusSearch string `mapstructure:"focus_search" toml:"focus_search"` 20 | UnfocusSearch string `mapstructure:"unfocus_search" toml:"unfocus_search"` 21 | QuickQuit string `mapstructure:"quick_quit" toml:"quick_quit"` 22 | } 23 | 24 | type NavKeybinds struct { 25 | Exit string `mapstructure:"exit" toml:"exit"` 26 | Dashboard string `mapstructure:"dasboard" toml:"dasboard"` 27 | Containers string `mapstructure:"containers" toml:"containers"` 28 | Images string `mapstructure:"images" toml:"images"` 29 | Networks string `mapstructure:"networks" toml:"networks"` 30 | Volumes string `mapstructure:"volumes" toml:"volumes"` 31 | Monitoring string `mapstructure:"monitoring" toml:"monitoring"` 32 | Vulnerability string `mapstructure:"vulnerability" toml:"vulnerability"` 33 | } 34 | 35 | type ContainersKeybinds struct { 36 | Start string `mapstructure:"start" toml:"start"` 37 | Exec string `mapstructure:"exec" toml:"exec"` 38 | Restart string `mapstructure:"restart" toml:"restart"` 39 | Stop string `mapstructure:"stop" toml:"stop"` 40 | Remove string `mapstructure:"remove" toml:"remove"` 41 | Pause string `mapstructure:"pause" toml:"pause"` 42 | Unpause string `mapstructure:"unpause" toml:"unpause"` 43 | PortMap string `mapstructure:"port_map" toml:"port_map"` 44 | ShowDetails string `mapstructure:"show_details" toml:"show_details"` 45 | ExitDetails string `mapstructure:"exit_details" toml:"exit_details"` 46 | } 47 | 48 | type FzfKeybinds struct { 49 | Up string `mapstructure:"up" toml:"up"` 50 | Down string `mapstructure:"down" toml:"down"` 51 | Enter string `mapstructure:"enter" toml:"enter"` 52 | Exit string `mapstructure:"exit" toml:"exit"` 53 | } 54 | 55 | type ImagesKeybinds struct { 56 | Remove string `mapstructure:"remove" toml:"remove"` 57 | Prune string `mapstructure:"prune" toml:"prune"` 58 | Push string `mapstructure:"push" toml:"push"` 59 | Pull string `mapstructure:"pull" toml:"pull"` 60 | Build string `mapstructure:"build" toml:"build"` 61 | Layers string `mapstructure:"layers" toml:"layers"` 62 | Exit string `mapstructure:"exit" toml:"exit"` 63 | Sync string `mapstructure:"sync" toml:"sync"` 64 | } 65 | 66 | type MonitorKeybinds struct { 67 | Search string `mapstructure:"search" toml:"search"` 68 | ExitSearch string `mapstructure:"exit_search" toml:"exit_search"` 69 | Export string `mapstructure:"export" toml:"export"` 70 | } 71 | 72 | type NetworkKeybinds struct { 73 | Remove string `mapstructure:"remove" toml:"remove"` 74 | Prune string `mapstructure:"prune" toml:"prune"` 75 | ShowDetails string `mapstructure:"show_details" toml:"show_details"` 76 | ExitDetails string `mapstructure:"exit_details" toml:"exit_details"` 77 | } 78 | 79 | type VolumeKeybinds struct { 80 | Remove string `mapstructure:"remove" toml:"remove"` 81 | Prune string `mapstructure:"prune" toml:"prune"` 82 | ShowDetails string `mapstructure:"show_details" toml:"show_details"` 83 | ExitDetails string `mapstructure:"exit_details" toml:"exit_details"` 84 | } 85 | 86 | type VulnerabilityKeybinds struct { 87 | FocusScanners string `mapstructure:"focus_scanners" toml:"focus_scanners"` 88 | FocusList string `mapstructure:"focus_list" toml:"focus_list"` 89 | Export string `mapstructure:"export" toml:"export"` 90 | } 91 | -------------------------------------------------------------------------------- /internal/config/default.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "runtime" 9 | ) 10 | 11 | func Default() Config { 12 | expDir := GetDefExportDir() 13 | 14 | if _, err := os.Stat(expDir); os.IsNotExist(err) { 15 | if err := os.MkdirAll(expDir, 0o755); err != nil { 16 | fmt.Printf("failed to create config dir: %s", err.Error()) 17 | os.Exit(1) 18 | } 19 | } 20 | 21 | return Config{ 22 | Global: Global{ 23 | ExportDir: expDir, 24 | Term: DetectTerminal(), 25 | }, 26 | Keybinds: Keybinds{ 27 | Global: GlobalKeybinds{ 28 | PageFinder: "tab", 29 | ListUp: "up", 30 | ListDown: "down", 31 | FocusSearch: "/", 32 | UnfocusSearch: "esc", 33 | QuickQuit: "q", 34 | }, 35 | Nav: NavKeybinds{ // TODO: In Docs 36 | Exit: "esc", 37 | Dashboard: "d", 38 | Containers: "c", 39 | Images: "i", 40 | Networks: "n", 41 | Volumes: "v", 42 | Monitoring: "m", 43 | Vulnerability: "s", 44 | }, 45 | Fzf: FzfKeybinds{ 46 | Up: "up", 47 | Down: "down", 48 | Enter: "enter", 49 | Exit: "esc", 50 | }, 51 | Container: ContainersKeybinds{ 52 | Start: "s", 53 | Stop: "t", 54 | Remove: "d", 55 | Restart: "r", 56 | Pause: "p", 57 | Unpause: "u", 58 | Exec: "e", 59 | ShowDetails: "enter", 60 | ExitDetails: "esc", 61 | PortMap: "m", 62 | }, 63 | Images: ImagesKeybinds{ 64 | Remove: "r", 65 | Prune: "d", 66 | Push: "p", 67 | Pull: "u", 68 | Build: "b", 69 | Layers: "l", 70 | Sync: "s", 71 | }, 72 | Network: NetworkKeybinds{ 73 | ExitDetails: "esc", 74 | ShowDetails: "enter", 75 | Prune: "p", 76 | Remove: "r", 77 | }, 78 | Volumes: VolumeKeybinds{ 79 | ExitDetails: "esc", 80 | ShowDetails: "enter", 81 | Prune: "p", 82 | Remove: "r", 83 | }, 84 | Vulnerability: VulnerabilityKeybinds{ 85 | FocusScanners: "S", 86 | FocusList: "L", 87 | }, 88 | Monitoring: MonitorKeybinds{ 89 | Search: "/", 90 | ExitSearch: "esc", 91 | }, 92 | }, 93 | Styles: Styles{ 94 | Text: "#cdd6f4", 95 | SubtitleText: "#74c7ec", 96 | SubtitleBg: "#313244", 97 | MenuSelectedBg: "#b4befe", 98 | MenuSelectedText: "#1e1e2e", 99 | FocusedBorder: "#b4befe", 100 | UnfocusedBorder: "#45475a", 101 | HelpKeyBg: "#313244", 102 | HelpKeyText: "#cdd6f4", 103 | HelpDescText: "#6c7086", 104 | ErrorText: "#f38ba8", 105 | ErrorBg: "#11111b", 106 | PopupBorder: "#74c7ec", 107 | PlaceholderText: "#585b70", 108 | MsgText: "#74c7ec", 109 | }, 110 | } 111 | } 112 | 113 | func GetDefExportDir() string { 114 | switch runtime.GOOS { 115 | case "linux", "darwin": 116 | home, _ := os.UserHomeDir() 117 | return filepath.Join(home, ".cruise") 118 | case "windows": 119 | home, _ := os.UserHomeDir() 120 | return filepath.Join(home, ".cruise", "export") 121 | default: 122 | cfg, _ := os.UserConfigDir() 123 | return cfg 124 | } 125 | } 126 | 127 | func DetectTerminal() string { 128 | if term := os.Getenv("TERMINAL"); term != "" { 129 | return term 130 | } 131 | 132 | switch runtime.GOOS { 133 | case "windows": 134 | // Prefer Windows Terminal (wt.exe) if installed 135 | if _, err := exec.LookPath("wt.exe"); err == nil { 136 | return "wt.exe" 137 | } 138 | if comspec := os.Getenv("ComSpec"); comspec != "" { 139 | return comspec 140 | } 141 | return "cmd.exe" 142 | 143 | case "darwin": 144 | return "open -a Terminal" 145 | 146 | case "linux": 147 | if _, err := exec.LookPath("x-terminal-emulator"); err == nil { 148 | return "x-terminal-emulator" 149 | } 150 | // Common terminals 151 | candidates := []string{ 152 | "gnome-terminal", 153 | "konsole", 154 | "xfce4-terminal", 155 | "xterm", 156 | } 157 | for _, c := range candidates { 158 | if _, err := exec.LookPath(c); err == nil { 159 | return c 160 | } 161 | } 162 | if sh := os.Getenv("SHELL"); sh != "" { 163 | return sh 164 | } 165 | return "xterm" 166 | 167 | default: 168 | return "sh" 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /internal/models/vulnerability/vulnerability.go: -------------------------------------------------------------------------------- 1 | package vulnerability 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | "time" 7 | 8 | "github.com/NucleoFusion/cruise/internal/docker" 9 | "github.com/NucleoFusion/cruise/internal/keymap" 10 | "github.com/NucleoFusion/cruise/internal/messages" 11 | styledhelp "github.com/NucleoFusion/cruise/internal/models/help" 12 | "github.com/NucleoFusion/cruise/internal/styles" 13 | "github.com/NucleoFusion/cruise/internal/utils" 14 | "github.com/charmbracelet/bubbles/key" 15 | tea "github.com/charmbracelet/bubbletea" 16 | "github.com/charmbracelet/lipgloss" 17 | ) 18 | 19 | type Vulnerability struct { 20 | Width int 21 | Height int 22 | Keymap keymap.VulnMap 23 | Help styledhelp.StyledHelp 24 | IsLoading bool 25 | ListFocused bool 26 | List *VulnerabilityList 27 | ScanList *ScanList 28 | } 29 | 30 | func NewVulnerability(w int, h int) *Vulnerability { 31 | return &Vulnerability{ 32 | Width: w, 33 | Height: h, 34 | IsLoading: true, 35 | ListFocused: true, 36 | Keymap: keymap.NewVulnMap(), 37 | Help: styledhelp.NewStyledHelp(keymap.NewVulnMap().Bindings(), w-2), 38 | List: NewVulnerabilityList(w-2-30, h-5-strings.Count(styles.VulnerabilityText, "\n")), //h-5 to account for styled help and title padding 39 | ScanList: NewScanList(30, h-5-strings.Count(styles.VulnerabilityText, "\n")), 40 | } 41 | } 42 | 43 | func (s *Vulnerability) Init() tea.Cmd { 44 | return tea.Batch(s.List.Init(), s.ScanList.Init()) 45 | } 46 | 47 | func (s *Vulnerability) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 48 | switch msg := msg.(type) { 49 | case messages.ScannerListMsg: 50 | var cmd tea.Cmd 51 | s.ScanList, cmd = s.ScanList.Update(msg) 52 | return s, cmd 53 | case messages.ScanResponse: 54 | var cmd tea.Cmd 55 | s.List, cmd = s.List.Update(msg) 56 | return s, cmd 57 | case messages.StartScanMsg: 58 | scanner, found := s.ScanList.GetSelected() 59 | if !found { 60 | return s, func() tea.Msg { 61 | return messages.ScanResponse{Arr: nil, Err: errors.New(scanner + ": scanner not found")} 62 | } 63 | } 64 | return s, tea.Tick(0, func(_ time.Time) tea.Msg { 65 | switch scanner { 66 | case "trivy": 67 | arr, err := docker.TrivyScanImage(msg.Img) 68 | return messages.ScanResponse{ 69 | Arr: utils.ToAnySlice(arr), 70 | Err: err, 71 | } 72 | case "grype": 73 | arr, err := docker.TrivyScanImage(msg.Img) 74 | return messages.ScanResponse{ 75 | Arr: utils.ToAnySlice(arr), 76 | Err: err, 77 | } 78 | default: 79 | return messages.ScanResponse{ 80 | Err: errors.New("invalid scanner"), 81 | } 82 | } 83 | }) 84 | 85 | case tea.KeyMsg: 86 | if s.List.Ti.Focused() { 87 | var cmd tea.Cmd 88 | s.List, cmd = s.List.Update(msg) 89 | return s, cmd 90 | } 91 | switch { 92 | case key.Matches(msg, keymap.QuickQuitKey()): 93 | return s, tea.Quit 94 | case key.Matches(msg, s.Keymap.FocusScanners): 95 | s.ListFocused = false 96 | return s, nil 97 | case key.Matches(msg, s.Keymap.FocusList): 98 | s.ListFocused = true 99 | return s, nil 100 | case key.Matches(msg, s.Keymap.Export): 101 | arr := make([]string, 0) 102 | for _, v := range s.List.Items { 103 | arr = append(arr, v.Format(s.Width)) 104 | } 105 | 106 | err := docker.Export(arr, "vuln") 107 | if err != nil { 108 | return s, utils.ReturnError("Vulnerability", "Error Exporting", err) 109 | } 110 | 111 | return s, utils.ReturnMsg("Vulnerability", "Exported Successfully", "exported vulnerabilities to export dir.") 112 | } 113 | } 114 | 115 | if s.ListFocused { 116 | var cmd tea.Cmd 117 | s.List, cmd = s.List.Update(msg) 118 | return s, cmd 119 | } else { 120 | var cmd tea.Cmd 121 | s.ScanList, cmd = s.ScanList.Update(msg) 122 | return s, cmd 123 | } 124 | } 125 | 126 | func (s *Vulnerability) View() string { 127 | if s.ListFocused { 128 | s.ScanList.Focused = false 129 | s.List.Focused = true 130 | } else { 131 | s.ScanList.Focused = true 132 | s.List.Focused = false 133 | } 134 | 135 | return styles.SceneStyle().Render( 136 | lipgloss.JoinVertical(lipgloss.Center, 137 | styles.TextStyle().Padding(1, 0).Render(styles.VulnerabilityText), 138 | lipgloss.JoinHorizontal(lipgloss.Center, s.ScanList.View(), s.List.View()), 139 | s.Help.View())) 140 | } 141 | -------------------------------------------------------------------------------- /internal/models/nav/nav.go: -------------------------------------------------------------------------------- 1 | package nav 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/NucleoFusion/cruise/internal/enums" 8 | "github.com/NucleoFusion/cruise/internal/keymap" 9 | "github.com/NucleoFusion/cruise/internal/messages" 10 | "github.com/NucleoFusion/cruise/internal/styles" 11 | "github.com/charmbracelet/bubbles/key" 12 | tea "github.com/charmbracelet/bubbletea" 13 | "github.com/charmbracelet/lipgloss" 14 | ) 15 | 16 | type Nav struct { 17 | Width int 18 | Height int 19 | Pages map[string][]enums.PageType 20 | Keymap keymap.NavMap 21 | KeymapMap map[enums.PageType]key.Binding 22 | PageNameMap map[enums.PageType]string 23 | } 24 | 25 | func NewNav(w, h int) *Nav { 26 | pgs := map[string][]enums.PageType{ 27 | "System": {enums.Home}, 28 | "Artifacts": {enums.Containers, enums.Images, enums.Networks, enums.Volumes}, 29 | "Ops": {enums.Vulnerability, enums.Monitoring}, 30 | } 31 | 32 | km := keymap.NewNavMap() 33 | 34 | kmp := map[enums.PageType]key.Binding{ 35 | enums.Home: km.Dashboard, 36 | enums.Containers: km.Containers, 37 | enums.Images: km.Images, 38 | enums.Networks: km.Networks, 39 | enums.Volumes: km.Volumes, 40 | enums.Monitoring: km.Monitoring, 41 | enums.Vulnerability: km.Vulnerability, 42 | } 43 | 44 | pgNameMap := map[enums.PageType]string{ 45 | enums.Home: "Dashboard", 46 | enums.Containers: "Containers", 47 | enums.Images: "Images", 48 | enums.Networks: "Networks", 49 | enums.Volumes: "Volumes", 50 | enums.Monitoring: "Monitoring", 51 | enums.Vulnerability: "Vulnerability", 52 | } 53 | 54 | return &Nav{ 55 | Width: w, 56 | Height: h, 57 | Pages: pgs, 58 | Keymap: km, 59 | KeymapMap: kmp, 60 | PageNameMap: pgNameMap, 61 | } 62 | } 63 | 64 | func (s *Nav) Init() tea.Cmd { return nil } 65 | 66 | func (s *Nav) Update(msg tea.Msg) (*Nav, tea.Cmd) { 67 | switch msg := msg.(type) { 68 | case tea.KeyMsg: 69 | switch { 70 | case key.Matches(msg, s.Keymap.Dashboard): 71 | return s, func() tea.Msg { return messages.ChangePg{Pg: enums.Home, Exited: false} } 72 | case key.Matches(msg, s.Keymap.Dashboard): 73 | return s, func() tea.Msg { return messages.ChangePg{Pg: enums.Home, Exited: false} } 74 | case key.Matches(msg, s.Keymap.Containers): 75 | return s, func() tea.Msg { return messages.ChangePg{Pg: enums.Containers, Exited: false} } 76 | case key.Matches(msg, s.Keymap.Images): 77 | return s, func() tea.Msg { return messages.ChangePg{Pg: enums.Images, Exited: false} } 78 | case key.Matches(msg, s.Keymap.Networks): 79 | return s, func() tea.Msg { return messages.ChangePg{Pg: enums.Networks, Exited: false} } 80 | case key.Matches(msg, s.Keymap.Volumes): 81 | return s, func() tea.Msg { return messages.ChangePg{Pg: enums.Volumes, Exited: false} } 82 | case key.Matches(msg, s.Keymap.Monitoring): 83 | return s, func() tea.Msg { return messages.ChangePg{Pg: enums.Monitoring, Exited: false} } 84 | case key.Matches(msg, s.Keymap.Vulnerability): 85 | return s, func() tea.Msg { return messages.ChangePg{Pg: enums.Vulnerability, Exited: false} } 86 | } 87 | } 88 | return s, nil 89 | } 90 | 91 | func (s *Nav) View() string { 92 | h := s.Height - strings.Count(styles.NavText, "\n") 93 | return lipgloss.JoinVertical(lipgloss.Center, 94 | styles.TextStyle().Render(styles.NavText), 95 | s.GetPages(s.Width, h, "System"), 96 | s.GetPages(s.Width, h, "Artifacts"), 97 | s.GetPages(s.Width, h, "Ops"), 98 | ) 99 | } 100 | 101 | func (s *Nav) GetPages(w, h int, category string) string { 102 | pgs := s.Pages[category] 103 | 104 | title := lipgloss.NewStyle().PaddingLeft(3).Render(lipgloss.PlaceHorizontal(w-3, lipgloss.Left, 105 | styles.TitleStyle().Render(fmt.Sprintf(" %s ", category)))) 106 | 107 | keybinds := "" 108 | keybindW := (w - 10) / 4 109 | 110 | for k, v := range pgs { 111 | keybind := lipgloss.PlaceHorizontal(keybindW, lipgloss.Left, lipgloss.JoinHorizontal(lipgloss.Center, 112 | styles.DetailKeyStyle().Render(fmt.Sprintf(" %s ", s.KeymapMap[v].Keys()[0])), 113 | " ", 114 | s.PageNameMap[v], 115 | )) 116 | 117 | if k%4 == 0 && k != 0 { 118 | keybinds += "\n" 119 | } 120 | 121 | keybinds += keybind 122 | } 123 | 124 | return lipgloss.Place(w, h/5, lipgloss.Left, lipgloss.Center, lipgloss.JoinVertical(lipgloss.Left, title, "\n", 125 | lipgloss.NewStyle().PaddingLeft(10).Render(keybinds))) 126 | } 127 | -------------------------------------------------------------------------------- /internal/docker/vulnerability.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os/exec" 7 | 8 | "github.com/NucleoFusion/cruise/internal/enums" 9 | "github.com/NucleoFusion/cruise/internal/utils" 10 | ) 11 | 12 | type Vulnerability struct { 13 | VulnID string 14 | Pkg string // PkgName 15 | Severity enums.Severity 16 | Title string 17 | Published string 18 | PrimaryURL string 19 | } 20 | 21 | func VulnHeaders(totalWidth int) string { 22 | w := totalWidth / 15 23 | return fmt.Sprintf( 24 | "%-*s %-*s %-*s %-*s %-*s %-*s", 25 | 2*w, "ID", 26 | 2*w, "Pkg", 27 | totalWidth-15*w, "Severity", 28 | 5*w, "Title", 29 | 2*w, "Date", 30 | 3*w, "URL") 31 | } 32 | 33 | func (s Vulnerability) Format(totalWidth int) string { 34 | w := totalWidth / 15 35 | return fmt.Sprintf( 36 | "%-*s %-*s %-*s %-*s %-*s %-*s", 37 | 2*w, utils.Shorten(s.VulnID, 2*w), 38 | 2*w, utils.Shorten(s.Pkg, 2*w), 39 | totalWidth-15*w, SeverityText(s.Severity), 40 | 5*w, utils.Shorten(s.Title, 5*w), 41 | 2*w, utils.Shorten(s.Published, 2*w), 42 | 3*w, utils.Shorten(s.PrimaryURL, 3*w)) 43 | } 44 | 45 | func SeverityText(sev enums.Severity) string { 46 | switch sev { 47 | case enums.Critical: 48 | return "CRITICAL" 49 | case enums.High: 50 | return "HIGH" 51 | case enums.Medium: 52 | return "MEDIUM" 53 | case enums.Low: 54 | return "LOW" 55 | default: 56 | return "UKNOWN" 57 | } 58 | } 59 | 60 | func ScannerAvailable(scanner string) bool { 61 | _, err := exec.Command("bash", "-c", scanner+" --version").CombinedOutput() // TODO: Config file based checker 62 | return err == nil 63 | } 64 | 65 | func TrivyScanImage(name string) ([]Vulnerability, error) { 66 | report := struct { 67 | Results []struct { 68 | Vulns []struct { 69 | VulnerabilityID string 70 | PkgName string 71 | InstalledVersion string 72 | Title string 73 | Severity string 74 | Description string 75 | PrimaryURL string 76 | PublishedDate string 77 | } `json:"Vulnerabilities"` 78 | } `json:"Results"` 79 | }{} 80 | 81 | out, err := exec.Command("bash", "-c", fmt.Sprintf("trivy image %s --format json", name)).Output() // TODO: Use custom shell (as provided by config) 82 | if err != nil { 83 | return nil, err 84 | } 85 | 86 | err = json.Unmarshal(out, &report) 87 | if err != nil { 88 | return nil, err 89 | } 90 | 91 | arr := make([]Vulnerability, 0) 92 | for _, res := range report.Results { 93 | for _, v := range res.Vulns { 94 | vuln := Vulnerability{ 95 | VulnID: v.VulnerabilityID, 96 | Title: v.Title, 97 | Severity: GetSeverity("trivy", v.Severity), 98 | Pkg: v.PkgName + " " + v.InstalledVersion, 99 | Published: v.PublishedDate, 100 | PrimaryURL: v.PrimaryURL, 101 | } 102 | arr = append(arr, vuln) 103 | } 104 | } 105 | 106 | return arr, nil 107 | } 108 | 109 | func GrypeScanImage(name string) ([]Vulnerability, error) { 110 | report := struct { 111 | Matches []struct { 112 | Vulnerability struct { 113 | ID string `json:"id"` 114 | Description string `json:"description"` 115 | Severity string `json:"severity"` 116 | URLs []string `json:"urls"` 117 | } `json:"vulnerability"` 118 | Artifact struct { 119 | Name string `json:"name"` 120 | Version string `json:"version"` 121 | } `json:"artifact"` 122 | } `json:"matches"` 123 | }{} 124 | 125 | out, err := exec.Command("bash", "-c", fmt.Sprintf("grype %s -o json", name)).Output() // TODO: Use custom shell (as provided by config) 126 | if err != nil { 127 | return nil, err 128 | } 129 | 130 | err = json.Unmarshal(out, &report) 131 | if err != nil { 132 | return nil, err 133 | } 134 | 135 | arr := make([]Vulnerability, 0) 136 | for _, v := range report.Matches { 137 | url := "NA" 138 | if len(v.Vulnerability.URLs) > 0 { 139 | url = v.Vulnerability.URLs[0] 140 | } 141 | vuln := Vulnerability{ 142 | VulnID: v.Vulnerability.ID, 143 | Title: v.Vulnerability.Description, 144 | Severity: GetSeverity("grype", v.Vulnerability.Severity), 145 | Pkg: v.Artifact.Name + " " + v.Artifact.Version, 146 | Published: "NA", 147 | PrimaryURL: url, 148 | } 149 | arr = append(arr, vuln) 150 | } 151 | 152 | return arr, nil 153 | } 154 | 155 | func GetSeverity(scanner, severity string) enums.Severity { 156 | switch severity { 157 | case "CRITICAL": 158 | return enums.Critical 159 | case "HIGH": 160 | return enums.High 161 | case "MEDIUM": 162 | return enums.Medium 163 | case "LOW": 164 | return enums.Low 165 | default: 166 | return enums.Unknown 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /internal/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "runtime" 8 | "strings" 9 | "time" 10 | 11 | "github.com/NucleoFusion/cruise/internal/messages" 12 | tea "github.com/charmbracelet/bubbletea" 13 | "github.com/docker/docker/api/types/container" 14 | ) 15 | 16 | func ReturnError(loc, title string, err error) tea.Cmd { 17 | return func() tea.Msg { 18 | return messages.ErrorMsg{ 19 | Locn: loc, 20 | Title: title, 21 | Msg: err.Error(), 22 | } 23 | } 24 | } 25 | 26 | func ReturnMsg(loc, title, msg string) tea.Cmd { 27 | return func() tea.Msg { 28 | return messages.MsgPopup{ 29 | Locn: loc, 30 | Title: title, 31 | Msg: msg, 32 | } 33 | } 34 | } 35 | 36 | func FormatDuration(d time.Duration) string { 37 | days := int(d.Hours()) / 24 38 | hours := int(d.Hours()) % 24 39 | minutes := int(d.Minutes()) % 60 40 | seconds := int(d.Seconds()) % 60 41 | 42 | if days > 0 { 43 | return fmt.Sprintf("%dd %dh %dm", days, hours, minutes) 44 | } else if hours > 0 { 45 | return fmt.Sprintf("%dh %dm", hours, minutes) 46 | } else if minutes > 0 { 47 | return fmt.Sprintf("%dm %ds", minutes, seconds) 48 | } 49 | return fmt.Sprintf("%ds", seconds) 50 | } 51 | 52 | func ShortID(id string) string { 53 | if len(id) > 12 { 54 | return id[:12] 55 | } 56 | return id 57 | } 58 | 59 | func CreatedAgo(ts int64) string { 60 | return time.Since(time.Unix(ts, 0)).Round(time.Second).String() + " ago" 61 | } 62 | 63 | func FormatPorts(ports []container.Port) string { 64 | if len(ports) == 0 { 65 | return "-" 66 | } 67 | var result []string 68 | for _, p := range ports { 69 | if p.PublicPort != 0 { 70 | result = append(result, fmt.Sprintf("%d->%d/%s", p.PublicPort, p.PrivatePort, p.Type)) 71 | } else { 72 | result = append(result, fmt.Sprintf("%d/%s", p.PrivatePort, p.Type)) 73 | } 74 | } 75 | return strings.Join(result, ",") 76 | } 77 | 78 | func FormatMounts(mounts []container.MountPoint) string { 79 | if len(mounts) == 0 { 80 | return "-" 81 | } 82 | var result []string 83 | for _, m := range mounts { 84 | result = append(result, m.Destination) 85 | } 86 | return strings.Join(result, ",") 87 | } 88 | 89 | func FormatSize(bytes int64) string { 90 | const unit = 1024 91 | if bytes < unit { 92 | return fmt.Sprintf("%d B", bytes) 93 | } 94 | div, exp := int64(unit), 0 95 | for n := bytes / unit; n >= unit; n /= unit { 96 | div *= unit 97 | exp++ 98 | } 99 | return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) 100 | } 101 | 102 | func Shorten(s string, max int) string { 103 | if len(s) <= max { 104 | return s 105 | } 106 | if max <= 3 { 107 | return s[:max] // no room for "..." 108 | } 109 | return s[:max-3] + "..." 110 | } 111 | 112 | func CalculateCPUPercent(stats container.StatsResponse) float64 { 113 | cpuDelta := float64(stats.CPUStats.CPUUsage.TotalUsage - stats.PreCPUStats.CPUUsage.TotalUsage) 114 | systemDelta := float64(stats.CPUStats.SystemUsage - stats.PreCPUStats.SystemUsage) 115 | onlineCPUs := float64(stats.CPUStats.OnlineCPUs) 116 | if onlineCPUs == 0 { 117 | onlineCPUs = float64(len(stats.CPUStats.CPUUsage.PercpuUsage)) // Fallback 118 | } 119 | 120 | if systemDelta > 0.0 && cpuDelta > 0.0 { 121 | return (cpuDelta / systemDelta) * onlineCPUs * 100.0 122 | } 123 | return 0.0 124 | } 125 | 126 | func CalculateMemoryPercent(stats container.StatsResponse) float64 { 127 | used := float64(stats.MemoryStats.Usage - stats.MemoryStats.Stats["cache"]) 128 | total := float64(stats.MemoryStats.Limit) 129 | 130 | percent := (used / total) * 100.0 131 | 132 | return percent 133 | } 134 | 135 | // Wraps the text to the given length and also limits no. of lines, adds a "..." line if exceeding. 136 | func WrapAndLimit(s string, maxLen, maxLines int) string { 137 | var lines []string 138 | 139 | for i := 0; i < len(s); i += maxLen { 140 | end := i + maxLen 141 | if end > len(s) { 142 | end = len(s) 143 | } 144 | lines = append(lines, s[i:end]) 145 | } 146 | 147 | if len(lines) > maxLines { 148 | lines = append(lines[:maxLines], "...") 149 | } 150 | 151 | return strings.Join(lines, "\n") 152 | } 153 | 154 | func ToAnySlice[T any](in []T) []any { 155 | out := make([]any, len(in)) 156 | for i := range in { 157 | out[i] = in[i] 158 | } 159 | return out 160 | } 161 | 162 | func GetCfgDir() string { 163 | switch runtime.GOOS { 164 | case "linux", "darwin": 165 | home, _ := os.UserHomeDir() 166 | return filepath.Join(home, ".config", "cruise") 167 | case "windows": 168 | home, _ := os.UserHomeDir() 169 | return filepath.Join(home, ".cruise") 170 | default: 171 | cfg, _ := os.UserConfigDir() 172 | return cfg 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cruise – Container Management Client 2 | 3 | > Terminal UI for managing containers with style and speed. 4 | 5 | **Cruise** is a powerful, intuitive, and fully-featured TUI (Terminal User Interface) for interacting with Containers. Built with Go and [Bubbletea](https://github.com/charmbracelet/bubbletea), it offers a visually rich, keyboard-first experience for managing containers, images, volumes, networks, logs and more — all from your terminal. 6 | 7 | ## Screenshots & Usage 8 | 9 |
10 | Screenshots 11 | 12 | 1 13 | 2 14 | 4 15 | 3 16 | 11 17 | 10 18 | 9 19 | 8 20 | 7 21 | 6 22 | 5 23 |
24 | 25 | Once [installed](#installation). You can run the app with `cruise`. 26 | 27 | 28 | ## Description 29 | 30 | Ever felt that CLI's for containerization tools are too lengthy or limited? Find yourself executing commands again and again for simple things? Wrote multiline commands just for a typo to ruin it? Well... Fret no more. Cruise - Is a TUI Container Management Client, fitting easily in your terminal-first dev workflow, while making repetitive work easy and interactive. 31 | 32 | > How is _cruise_ different from existing solutions? 33 | 34 | Existing applications are limited in what they do, they serve as mostly a monitoring service, _not_ a management service let alone a Client. 35 | 36 | With Cruise you can: 37 | - Manage Lifecycles of Containers, Images, Volumes, Networks. 38 | - Have a centralized Monitoring service 39 | - Scan images for vulnerabilities 40 | - Get Detailed view on Docker Artifacts 41 | - and more to come! 42 | 43 | **NOTE**: Although cruise currently only supports docker, through some more refactor and expansion we plan to expand the scope of cruise to support multiple containerization tools and also several other QoL features. 44 | 45 | ### Tech Stack 46 | 47 | - **Go** – High performance, robust concurrency 48 | - **Bubbletea** – Elegant terminal UI framework 49 | - **Charm ecosystem** – [Lipgloss](https://github.com/charmbracelet/lipgloss), [Bubbles](https://github.com/charmbracelet/bubbles), [Glamour](https://github.com/charmbracelet/glamour) 50 | - **Docker SDK for Go** – Deep Docker integration 51 | - **Trivy / Grype** – Vulnerability scanning 52 | - **Viper** – Configuration management 53 | 54 | ## Future Plans 55 | 56 | Please check out the roadmap [here](ROADMAP.md). 57 | 58 | ### Expected Growth Plan 59 | 60 | - Support multiple popular containerization tools 61 | - Special Support for Docker Compose. 62 | - Support Managing Compose Lifecycles 63 | - Compose Editing 64 | - and more... 65 | - Support Advanced Image Manipulation. 66 | - Advanced Monitoring Support 67 | - Notifications/Alerts 68 | - A standalone/integrated daemon service for live log/monitoring 69 | - Registry & Image Build Configuration 70 | - Harbor Support? 71 | 72 | - Support for Container Orchestration tools 73 | 74 | **This list is just specs I can list from the top of my head, actual integration and selection will vary.** 75 | 76 | ## Installation 77 | 78 | Refer to the installation docs [here](https://cruise-org.github.io/cruise/docs/install.html). 79 | 80 | ## Contributing 81 | 82 | Please check out [CONTRIBUTING.md](CONTRIBUTING.md) for more. 83 | 84 | 85 | ## License 86 | 87 | MIT License – see [LICENSE](LICENSE) for details. 88 | 89 | ## Credits 90 | 91 | Built by [Nucleo](https://github.com/NucleoFusion). 92 | 93 | Inspiration and Advice from [SourcewareLab](https://github.com/SourcewareLab). 94 | 95 | Special Thanks to [Hegedus Mark](https://github.com/hegedus-mark) and [Mongy](https://github.com/A-Cer23). 96 | 97 | 98 | -------------------------------------------------------------------------------- /internal/models/vulnerability/list.go: -------------------------------------------------------------------------------- 1 | package vulnerability 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/NucleoFusion/cruise/internal/colors" 7 | "github.com/NucleoFusion/cruise/internal/config" 8 | "github.com/NucleoFusion/cruise/internal/docker" 9 | "github.com/NucleoFusion/cruise/internal/messages" 10 | "github.com/NucleoFusion/cruise/internal/styles" 11 | "github.com/NucleoFusion/cruise/internal/utils" 12 | "github.com/charmbracelet/bubbles/textinput" 13 | "github.com/charmbracelet/bubbles/viewport" 14 | tea "github.com/charmbracelet/bubbletea" 15 | "github.com/charmbracelet/lipgloss" 16 | ) 17 | 18 | type VulnerabilityList struct { 19 | Width int 20 | Height int 21 | Items []docker.Vulnerability 22 | SelectedIndex int 23 | Focused bool 24 | Ti textinput.Model 25 | Vp viewport.Model 26 | IsScanning bool 27 | } 28 | 29 | func NewVulnerabilityList(w int, h int) *VulnerabilityList { 30 | ti := textinput.New() 31 | ti.Width = w - 11 32 | ti.Prompt = " Image: " 33 | ti.Placeholder = "Enter image name to scan..." 34 | 35 | ti.PromptStyle = lipgloss.NewStyle().Foreground(colors.Load().FocusedBorder) 36 | ti.PlaceholderStyle = lipgloss.NewStyle().Foreground(colors.Load().PlaceholderText) 37 | ti.TextStyle = styles.TextStyle() 38 | 39 | vp := viewport.New(w-4, h-7) 40 | 41 | return &VulnerabilityList{ 42 | Width: w, 43 | Height: h, 44 | Ti: ti, 45 | SelectedIndex: 0, 46 | Vp: vp, 47 | } 48 | } 49 | 50 | func (s *VulnerabilityList) Init() tea.Cmd { 51 | return nil 52 | } 53 | 54 | func (s *VulnerabilityList) Update(msg tea.Msg) (*VulnerabilityList, tea.Cmd) { 55 | switch msg := msg.(type) { 56 | case messages.ScanResponse: 57 | s.IsScanning = false 58 | if msg.Err != nil { 59 | return s, utils.ReturnError("Vulnerability Page", "Error While Scanning", msg.Err) 60 | } 61 | arr, err := Convert(msg.Arr) 62 | if err != nil { 63 | return s, utils.ReturnError("Vulnerability Page", "Error While Parsing", err) 64 | } 65 | 66 | s.Items = arr 67 | return s, nil 68 | case tea.KeyMsg: 69 | if s.Ti.Focused() { 70 | if msg.String() == config.Cfg.Keybinds.Global.UnfocusSearch { 71 | s.Ti.Blur() 72 | return s, nil 73 | } 74 | var cmd tea.Cmd 75 | s.Ti, cmd = s.Ti.Update(msg) 76 | s.UpdateList() 77 | return s, cmd 78 | } 79 | switch msg.String() { 80 | case config.Cfg.Keybinds.Global.FocusSearch: 81 | s.Ti.Focus() 82 | return s, nil 83 | case config.Cfg.Keybinds.Global.ListDown: 84 | if len(s.Items)-1 > s.SelectedIndex { 85 | s.SelectedIndex += 1 86 | } 87 | if s.SelectedIndex > s.Vp.Height+s.Vp.YOffset-1 { 88 | s.Vp.YOffset += 1 89 | } 90 | return s, nil 91 | case config.Cfg.Keybinds.Global.ListUp: 92 | if s.SelectedIndex > 0 { 93 | s.SelectedIndex -= 1 94 | } 95 | if s.SelectedIndex < s.Vp.YOffset { 96 | s.Vp.YOffset -= 1 97 | } 98 | return s, nil 99 | case "enter": 100 | if !s.Ti.Focused() { 101 | return s, nil 102 | } 103 | fmt.Println("scan") 104 | s.IsScanning = true 105 | return s, func() tea.Msg { 106 | return messages.StartScanMsg{Img: s.Ti.Value()} 107 | } 108 | } 109 | } 110 | 111 | var cmd tea.Cmd 112 | s.Ti, cmd = s.Ti.Update(msg) 113 | return s, cmd 114 | } 115 | 116 | func (s *VulnerabilityList) View() string { 117 | if len(s.Items) == 0 { 118 | s.Vp.SetContent("Run the Scanner to get Output") 119 | } 120 | 121 | style := styles.PageStyle() 122 | if !s.Focused { 123 | style = style.BorderForeground(colors.Load().UnfocusedBorder) 124 | } 125 | 126 | if s.IsScanning { 127 | return lipgloss.JoinVertical(lipgloss.Center, 128 | style.Render(s.Ti.View()), 129 | style.Padding(1).Render(lipgloss.Place(s.Width-4, s.Height-5, lipgloss.Center, lipgloss.Center, "Scanning..."))) 130 | } 131 | 132 | s.UpdateList() 133 | 134 | return lipgloss.JoinVertical(lipgloss.Center, 135 | style.Render(s.Ti.View()), 136 | style.Padding(1).Render( 137 | lipgloss.JoinVertical(lipgloss.Center, s.Vp.View()))) 138 | } 139 | 140 | func (s *VulnerabilityList) UpdateList() { 141 | 142 | text := lipgloss.NewStyle().Bold(true).Render(docker.VulnHeaders(s.Vp.Width)+"\n") + "\n" 143 | 144 | for k, v := range s.Items { 145 | line := v.Format(s.Vp.Width - 4) 146 | 147 | if k == s.SelectedIndex { 148 | line = lipgloss.NewStyle().Background(colors.Load().MenuSelectedBg).Foreground(colors.Load().MenuSelectedText).Render(line) 149 | } else { 150 | line = styles.TextStyle().Render(line) 151 | } 152 | 153 | text += line + "\n" 154 | } 155 | 156 | //s.Vp.SetContent(text) 157 | } 158 | 159 | func Convert(in []any) ([]docker.Vulnerability, error) { 160 | out := make([]docker.Vulnerability, len(in)) 161 | for i, v := range in { 162 | mt, ok := v.(docker.Vulnerability) 163 | if !ok { 164 | return nil, fmt.Errorf("element %d is not MyType", i) 165 | } 166 | out[i] = mt 167 | } 168 | return out, nil 169 | } 170 | -------------------------------------------------------------------------------- /internal/docker/containers.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "strings" 8 | 9 | "github.com/NucleoFusion/cruise/internal/utils" 10 | "github.com/docker/docker/api/types/container" 11 | "github.com/docker/docker/api/types/events" 12 | ) 13 | 14 | type Details struct { 15 | IsLoading bool 16 | CPU float64 17 | Mem float64 18 | Size float64 19 | Logs []string 20 | EventStream chan *events.Message 21 | } 22 | 23 | func GetPorts() ([]string, error) { 24 | conts, err := GetContainers() 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | arr := make([]string, 0) 30 | for _, cnt := range conts { 31 | inp, err := cli.ContainerInspect(context.Background(), cnt.ID) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | for port, bindings := range inp.NetworkSettings.Ports { 37 | for _, b := range bindings { 38 | arr = append(arr, fmt.Sprintf("%s --> %s:%s | %s", port, b.HostIP, b.HostPort, cnt.Names[0])) 39 | } 40 | } 41 | } 42 | 43 | return arr, nil 44 | } 45 | 46 | func InspectContainer(id string) (container.InspectResponse, error) { 47 | return cli.ContainerInspect(context.Background(), id) 48 | } 49 | 50 | func GetNumContainers() int { 51 | containers, err := cli.ContainerList(context.Background(), container.ListOptions{All: true}) 52 | if err != nil { 53 | fmt.Println("Error: " + err.Error()) 54 | return -1 55 | } 56 | 57 | return len(containers) 58 | } 59 | 60 | func GetContainers() ([]container.Summary, error) { 61 | return cli.ContainerList(context.Background(), container.ListOptions{All: true}) 62 | } 63 | 64 | // Gives a realtime updater 65 | func GetContainerStats(id string) (container.StatsResponseReader, error) { 66 | return cli.ContainerStats(context.Background(), id, true) 67 | } 68 | 69 | // Stream of logs 70 | func GetContainerLogs(ctx context.Context, id string, tail int) (io.ReadCloser, error) { 71 | return cli.ContainerLogs(ctx, id, container.LogsOptions{ 72 | ShowStdout: true, 73 | ShowStderr: true, 74 | Tail: fmt.Sprintf("%d", tail), 75 | Follow: true, 76 | Timestamps: false, 77 | Details: false, 78 | }) 79 | } 80 | 81 | func ContainerFormattedSummary(item container.Summary, width int) string { 82 | name := "-" 83 | if len(item.Names) > 0 { 84 | name = strings.TrimPrefix(item.Names[0], "/") 85 | } 86 | 87 | format := strings.Repeat(fmt.Sprintf("%%-%ds ", width), 9) 88 | 89 | return fmt.Sprintf( 90 | format, 91 | utils.ShortID(item.ID), 92 | utils.Shorten(name, width), 93 | utils.Shorten(item.Image, width), 94 | utils.CreatedAgo(item.Created), 95 | utils.Shorten(utils.FormatPorts(item.Ports), width), 96 | item.State, 97 | utils.Shorten(item.Status, width), 98 | utils.Shorten(utils.FormatMounts(item.Mounts), width), 99 | utils.FormatSize(item.SizeRootFs), 100 | ) 101 | } 102 | 103 | func ContainerHeaders(width int) string { 104 | format := strings.Repeat(fmt.Sprintf("%%-%ds ", width), 9) 105 | 106 | return fmt.Sprintf( 107 | format, 108 | "ID", 109 | "Name", 110 | "Image", 111 | "Created", 112 | "Ports", 113 | "State", 114 | "Status", 115 | "Mounts", 116 | "Size", 117 | ) 118 | } 119 | 120 | func GetBlkio(stats container.StatsResponse, platform string) (uint64, uint64) { 121 | switch platform { 122 | case "windows": 123 | return stats.StorageStats.ReadSizeBytes, stats.StorageStats.WriteSizeBytes 124 | case "linux": 125 | var read, write uint64 126 | for _, entry := range stats.BlkioStats.IoServiceBytesRecursive { 127 | switch entry.Op { 128 | case "Read": 129 | read += entry.Value 130 | case "Write": 131 | write += entry.Value 132 | } 133 | } 134 | return read, write 135 | } 136 | return 0, 0 137 | } 138 | 139 | func StartContainer(ID string) error { 140 | err := cli.ContainerStart(context.Background(), ID, container.StartOptions{}) 141 | if err != nil { 142 | return err 143 | } 144 | 145 | return nil 146 | } 147 | 148 | func RestartContainer(ID string) error { 149 | err := cli.ContainerRestart(context.Background(), ID, container.StopOptions{}) 150 | if err != nil { 151 | return err 152 | } 153 | 154 | return nil 155 | } 156 | 157 | func RemoveContainer(ID string) error { 158 | err := cli.ContainerRemove(context.Background(), ID, container.RemoveOptions{}) 159 | if err != nil { 160 | return err 161 | } 162 | 163 | return nil 164 | } 165 | 166 | func PauseContainer(ID string) error { 167 | err := cli.ContainerPause(context.Background(), ID) 168 | if err != nil { 169 | return err 170 | } 171 | 172 | return nil 173 | } 174 | 175 | func UnpauseContainer(ID string) error { 176 | err := cli.ContainerUnpause(context.Background(), ID) 177 | if err != nil { 178 | return err 179 | } 180 | 181 | return nil 182 | } 183 | 184 | func StopContainer(ID string) error { 185 | err := cli.ContainerStop(context.Background(), ID, container.StopOptions{}) 186 | if err != nil { 187 | return err 188 | } 189 | 190 | return nil 191 | } 192 | -------------------------------------------------------------------------------- /internal/models/volumes/list.go: -------------------------------------------------------------------------------- 1 | package volumes 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "time" 7 | 8 | "github.com/NucleoFusion/cruise/internal/colors" 9 | "github.com/NucleoFusion/cruise/internal/config" 10 | "github.com/NucleoFusion/cruise/internal/docker" 11 | "github.com/NucleoFusion/cruise/internal/messages" 12 | "github.com/NucleoFusion/cruise/internal/styles" 13 | "github.com/NucleoFusion/cruise/internal/utils" 14 | "github.com/charmbracelet/bubbles/textinput" 15 | "github.com/charmbracelet/bubbles/viewport" 16 | tea "github.com/charmbracelet/bubbletea" 17 | "github.com/charmbracelet/lipgloss" 18 | "github.com/docker/docker/api/types/volume" 19 | "github.com/lithammer/fuzzysearch/fuzzy" 20 | ) 21 | 22 | type VolumeList struct { 23 | Width int 24 | Height int 25 | Items []*volume.Volume 26 | FilteredItems []*volume.Volume 27 | SelectedIndex int 28 | Ti textinput.Model 29 | Vp viewport.Model 30 | } 31 | 32 | func NewVolumeList(w int, h int) *VolumeList { 33 | ti := textinput.New() 34 | ti.Width = w - 12 35 | ti.Prompt = " Search: " 36 | ti.Placeholder = "Press '/' to search..." 37 | 38 | ti.PromptStyle = lipgloss.NewStyle().Foreground(colors.Load().FocusedBorder) 39 | ti.PlaceholderStyle = lipgloss.NewStyle().Foreground(colors.Load().PlaceholderText) 40 | ti.TextStyle = styles.TextStyle() 41 | 42 | vp := viewport.New(w, h-3) 43 | vp.Style = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(colors.Load().FocusedBorder). 44 | Padding(1).Foreground(colors.Load().Text) 45 | 46 | return &VolumeList{ 47 | Width: w, 48 | Height: h, 49 | Ti: ti, 50 | SelectedIndex: 0, 51 | Vp: vp, 52 | } 53 | } 54 | 55 | func (s *VolumeList) Init() tea.Cmd { 56 | return tea.Tick(0, func(_ time.Time) tea.Msg { 57 | vols, err := docker.GetVolumes() 58 | if err != nil { 59 | fmt.Println(err) 60 | return utils.ReturnError("Volumes Page", "Error Querying Volumes", err) 61 | } 62 | return messages.VolumesReadyMsg{Items: vols.Volumes} 63 | }) 64 | } 65 | 66 | func (s *VolumeList) Update(msg tea.Msg) (*VolumeList, tea.Cmd) { 67 | switch msg := msg.(type) { 68 | case messages.VolumesReadyMsg: 69 | s.Items = msg.Items 70 | s.FilteredItems = msg.Items 71 | return s, nil 72 | case tea.KeyMsg: 73 | if s.Ti.Focused() { 74 | if msg.String() == config.Cfg.Keybinds.Global.UnfocusSearch { 75 | s.Ti.Blur() 76 | return s, nil 77 | } 78 | var cmd tea.Cmd 79 | s.Ti, cmd = s.Ti.Update(msg) 80 | s.Filter(s.Ti.Value()) 81 | s.UpdateList() 82 | return s, cmd 83 | } 84 | switch msg.String() { 85 | case config.Cfg.Keybinds.Global.FocusSearch: 86 | s.Ti.Focus() 87 | return s, nil 88 | case config.Cfg.Keybinds.Global.ListDown: 89 | if len(s.FilteredItems)-1 > s.SelectedIndex { 90 | s.SelectedIndex += 1 91 | } 92 | if s.SelectedIndex > s.Vp.Height+s.Vp.YOffset-7 { // -2 for border and sosething else, idk breaks otherwise 93 | s.Vp.YOffset += 1 94 | } 95 | s.UpdateList() 96 | return s, nil 97 | case config.Cfg.Keybinds.Global.ListUp: 98 | if 0 < s.SelectedIndex { 99 | s.SelectedIndex -= 1 100 | } 101 | if s.SelectedIndex < s.Vp.YOffset { 102 | s.Vp.YOffset -= 1 103 | } 104 | s.UpdateList() 105 | return s, nil 106 | } 107 | } 108 | return s, nil 109 | } 110 | 111 | func (s *VolumeList) View() string { 112 | if len(s.Items) == 0 { 113 | return lipgloss.Place(s.Width-2, s.Height, lipgloss.Center, lipgloss.Center, "No Volumes Found!") 114 | } 115 | 116 | style := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(colors.Load().FocusedBorder) 117 | 118 | s.UpdateList() 119 | 120 | return lipgloss.JoinVertical(lipgloss.Center, 121 | style.Render(s.Ti.View()), 122 | s.Vp.View()) 123 | } 124 | 125 | func (s *VolumeList) UpdateList() { 126 | text := lipgloss.NewStyle().Bold(true).Render(docker.VolumesHeaders(s.Width-2)+"\n") + "\n" 127 | 128 | for k, v := range s.FilteredItems { 129 | if v == nil { 130 | continue 131 | } 132 | 133 | line := docker.VolumesFormattedSummary(*v, s.Width-2) 134 | 135 | if k == s.SelectedIndex { 136 | line = styles.SelectedStyle().Render(line) 137 | } else { 138 | line = styles.TextStyle().Render(line) 139 | } 140 | 141 | text += line + "\n" 142 | } 143 | 144 | s.Vp.SetContent(text) 145 | } 146 | 147 | func (s *VolumeList) Filter(val string) { 148 | formatted := make([]string, len(s.Items)) 149 | originals := make([]*volume.Volume, len(s.Items)) 150 | 151 | for i, v := range s.Items { 152 | str := docker.VolumesFormattedSummary(*v, s.Width-2) 153 | formatted[i] = str 154 | originals[i] = v 155 | } 156 | 157 | ranked := fuzzy.RankFindFold(val, formatted) 158 | sort.Sort(ranked) 159 | 160 | result := make([]*volume.Volume, len(ranked)) 161 | for i, r := range ranked { 162 | result[i] = originals[r.OriginalIndex] 163 | } 164 | 165 | s.FilteredItems = result 166 | 167 | if len(s.FilteredItems) <= s.SelectedIndex { 168 | s.SelectedIndex = len(s.FilteredItems) - 1 169 | } 170 | } 171 | 172 | func (s *VolumeList) GetCurrentItem() volume.Volume { 173 | return *s.FilteredItems[s.SelectedIndex] 174 | } 175 | -------------------------------------------------------------------------------- /internal/models/networks/list.go: -------------------------------------------------------------------------------- 1 | package networks 2 | 3 | import ( 4 | "sort" 5 | "time" 6 | 7 | "github.com/NucleoFusion/cruise/internal/colors" 8 | "github.com/NucleoFusion/cruise/internal/config" 9 | "github.com/NucleoFusion/cruise/internal/docker" 10 | "github.com/NucleoFusion/cruise/internal/messages" 11 | "github.com/NucleoFusion/cruise/internal/styles" 12 | "github.com/NucleoFusion/cruise/internal/utils" 13 | "github.com/charmbracelet/bubbles/textinput" 14 | "github.com/charmbracelet/bubbles/viewport" 15 | tea "github.com/charmbracelet/bubbletea" 16 | "github.com/charmbracelet/lipgloss" 17 | "github.com/docker/docker/api/types/network" 18 | "github.com/lithammer/fuzzysearch/fuzzy" 19 | ) 20 | 21 | type NetworkList struct { 22 | Width int 23 | Height int 24 | Items []network.Summary 25 | FilteredItems []network.Summary 26 | SelectedIndex int 27 | Ti textinput.Model 28 | Vp viewport.Model 29 | } 30 | 31 | func NewNetworkList(w int, h int) *NetworkList { 32 | ti := textinput.New() 33 | ti.Width = w - 12 34 | ti.Prompt = " Search: " 35 | ti.Placeholder = "Press '/' to search..." 36 | 37 | ti.PromptStyle = lipgloss.NewStyle().Foreground(colors.Load().FocusedBorder) 38 | ti.PlaceholderStyle = lipgloss.NewStyle().Foreground(colors.Load().PlaceholderText) 39 | ti.TextStyle = styles.TextStyle() 40 | 41 | vp := viewport.New(w, h-3) 42 | vp.Style = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(colors.Load().FocusedBorder). 43 | Padding(1).Foreground(colors.Load().Text) 44 | 45 | return &NetworkList{ 46 | Width: w, 47 | Height: h, 48 | Ti: ti, 49 | SelectedIndex: 0, 50 | Vp: vp, 51 | } 52 | } 53 | 54 | func (s *NetworkList) Init() tea.Cmd { 55 | return tea.Tick(0, func(_ time.Time) tea.Msg { 56 | images, err := docker.GetNetworks() 57 | if err != nil { 58 | return utils.ReturnError("Networks Page", "Error Querying Networks", err) 59 | } 60 | return messages.NetworksReadyMsg{Items: images} 61 | }) 62 | } 63 | 64 | func (s *NetworkList) Update(msg tea.Msg) (*NetworkList, tea.Cmd) { 65 | switch msg := msg.(type) { 66 | case messages.NetworksReadyMsg: 67 | s.Items = msg.Items 68 | s.FilteredItems = msg.Items 69 | return s, tea.Tick(3*time.Second, func(_ time.Time) tea.Msg { 70 | images, err := docker.GetNetworks() 71 | if err != nil { 72 | return utils.ReturnError("Networks Page", "Error Querying Networks", err) 73 | } 74 | return messages.NetworksReadyMsg{Items: images} 75 | }) 76 | 77 | case tea.KeyMsg: 78 | if s.Ti.Focused() { 79 | if msg.String() == config.Cfg.Keybinds.Global.UnfocusSearch { 80 | s.Ti.Blur() 81 | return s, nil 82 | } 83 | var cmd tea.Cmd 84 | s.Ti, cmd = s.Ti.Update(msg) 85 | s.Filter(s.Ti.Value()) 86 | s.UpdateList() 87 | return s, cmd 88 | } 89 | switch msg.String() { 90 | case config.Cfg.Keybinds.Global.FocusSearch: 91 | s.Ti.Focus() 92 | return s, nil 93 | case config.Cfg.Keybinds.Global.ListDown: 94 | if len(s.FilteredItems)-1 > s.SelectedIndex { 95 | s.SelectedIndex += 1 96 | } 97 | if s.SelectedIndex > s.Vp.Height+s.Vp.YOffset-7 { // -2 for border and sosething else, idk breaks otherwise 98 | s.Vp.YOffset += 1 99 | } 100 | s.UpdateList() 101 | return s, nil 102 | case config.Cfg.Keybinds.Global.ListUp: 103 | if 0 < s.SelectedIndex { 104 | s.SelectedIndex -= 1 105 | } 106 | if s.SelectedIndex < s.Vp.YOffset { 107 | s.Vp.YOffset -= 1 108 | } 109 | s.UpdateList() 110 | return s, nil 111 | } 112 | } 113 | return s, nil 114 | } 115 | 116 | func (s *NetworkList) View() string { 117 | if len(s.Items) == 0 { 118 | return lipgloss.Place(s.Width-2, s.Height, lipgloss.Center, lipgloss.Center, "No Containers Found!") 119 | } 120 | 121 | style := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(colors.Load().FocusedBorder) 122 | 123 | s.UpdateList() 124 | 125 | return lipgloss.JoinVertical(lipgloss.Center, 126 | style.Render(s.Ti.View()), 127 | s.Vp.View()) 128 | } 129 | 130 | func (s *NetworkList) UpdateList() { 131 | text := lipgloss.NewStyle().Bold(true).Render(docker.NetworksHeaders(s.Width-2)+"\n") + "\n" 132 | 133 | for k, v := range s.FilteredItems { 134 | line := docker.NetworksFormattedSummary(v, s.Width-2) 135 | 136 | if k == s.SelectedIndex { 137 | line = lipgloss.NewStyle().Background(colors.Load().MenuSelectedBg).Foreground(colors.Load().MenuSelectedText).Render(line) 138 | } else { 139 | line = styles.TextStyle().Render(line) 140 | } 141 | 142 | text += line + "\n" 143 | } 144 | 145 | s.Vp.SetContent(text) 146 | } 147 | 148 | func (s *NetworkList) Filter(val string) { 149 | formatted := make([]string, len(s.Items)) 150 | originals := make([]network.Summary, len(s.Items)) 151 | 152 | for i, v := range s.Items { 153 | str := docker.NetworksFormattedSummary(v, s.Width-2) 154 | formatted[i] = str 155 | originals[i] = v 156 | } 157 | 158 | ranked := fuzzy.RankFindFold(val, formatted) 159 | sort.Sort(ranked) 160 | 161 | result := make([]network.Summary, len(ranked)) 162 | for i, r := range ranked { 163 | result[i] = originals[r.OriginalIndex] 164 | } 165 | 166 | s.FilteredItems = result 167 | 168 | if len(s.FilteredItems) <= s.SelectedIndex { 169 | s.SelectedIndex = len(s.FilteredItems) - 1 170 | } 171 | } 172 | 173 | func (s *NetworkList) GetCurrentItem() network.Summary { 174 | return s.FilteredItems[s.SelectedIndex] 175 | } 176 | -------------------------------------------------------------------------------- /internal/models/containers/list.go: -------------------------------------------------------------------------------- 1 | package containers 2 | 3 | import ( 4 | "context" 5 | "sort" 6 | "time" 7 | 8 | "github.com/NucleoFusion/cruise/internal/colors" 9 | "github.com/NucleoFusion/cruise/internal/config" 10 | "github.com/NucleoFusion/cruise/internal/docker" 11 | "github.com/NucleoFusion/cruise/internal/messages" 12 | "github.com/NucleoFusion/cruise/internal/styles" 13 | "github.com/charmbracelet/bubbles/textinput" 14 | "github.com/charmbracelet/bubbles/viewport" 15 | tea "github.com/charmbracelet/bubbletea" 16 | "github.com/charmbracelet/lipgloss" 17 | "github.com/docker/docker/api/types/container" 18 | "github.com/lithammer/fuzzysearch/fuzzy" 19 | ) 20 | 21 | type LogStreamer struct { 22 | ctx context.Context 23 | cancel context.CancelFunc 24 | lines chan string 25 | } 26 | 27 | type ContainerList struct { 28 | Width int 29 | Height int 30 | Items []container.Summary 31 | Err error 32 | FilteredItems []container.Summary 33 | SelectedIndex int 34 | Ti textinput.Model 35 | Vp viewport.Model 36 | } 37 | 38 | func NewContainerList(w int, h int) *ContainerList { 39 | ti := textinput.New() 40 | ti.Width = w - 12 41 | ti.Prompt = " Search: " 42 | ti.Placeholder = "Press '/' to search..." 43 | 44 | ti.PromptStyle = lipgloss.NewStyle().Foreground(colors.Load().FocusedBorder) 45 | ti.PlaceholderStyle = lipgloss.NewStyle().Foreground(colors.Load().PlaceholderText) 46 | ti.TextStyle = styles.TextStyle() 47 | 48 | vp := viewport.New(w, h-3) //h-3 to account for searchbar 49 | vp.Style = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(colors.Load().FocusedBorder). 50 | Padding(1).Foreground(colors.Load().Text) 51 | 52 | return &ContainerList{ 53 | Width: w, 54 | Height: h, 55 | Ti: ti, 56 | SelectedIndex: 0, 57 | Vp: vp, 58 | } 59 | } 60 | 61 | func (s *ContainerList) Init() tea.Cmd { 62 | return tea.Tick(0, func(_ time.Time) tea.Msg { 63 | items, err := docker.GetContainers() 64 | return messages.ContainerReadyMsg{ 65 | Items: items, 66 | Err: err, 67 | } 68 | }) 69 | } 70 | 71 | func (s *ContainerList) Update(msg tea.Msg) (*ContainerList, tea.Cmd) { 72 | switch msg := msg.(type) { 73 | case messages.ContainerReadyMsg: 74 | s.Items = msg.Items 75 | s.FilteredItems = msg.Items 76 | s.Err = msg.Err 77 | return s, tea.Tick(3*time.Second, func(_ time.Time) tea.Msg { 78 | items, err := docker.GetContainers() 79 | return messages.ContainerReadyMsg{ 80 | Items: items, 81 | Err: err, 82 | } 83 | }) 84 | 85 | case tea.KeyMsg: 86 | if s.Ti.Focused() { 87 | if msg.String() == config.Cfg.Keybinds.Global.UnfocusSearch { 88 | s.Ti.Blur() 89 | return s, nil 90 | } 91 | var cmd tea.Cmd 92 | s.Ti, cmd = s.Ti.Update(msg) 93 | s.Filter(s.Ti.Value()) 94 | s.UpdateList() 95 | return s, cmd 96 | } 97 | switch msg.String() { 98 | case config.Cfg.Keybinds.Global.FocusSearch: 99 | s.Ti.Focus() 100 | return s, nil 101 | case config.Cfg.Keybinds.Global.ListDown: 102 | if len(s.FilteredItems)-1 > s.SelectedIndex { 103 | s.SelectedIndex += 1 104 | } 105 | if s.SelectedIndex > s.Vp.Height+s.Vp.YOffset-3 { // -2 for border and sosething else, idk breaks otherwise 106 | s.Vp.YOffset += 1 107 | } 108 | s.UpdateList() 109 | return s, nil 110 | case config.Cfg.Keybinds.Global.ListUp: 111 | if 0 < s.SelectedIndex { 112 | s.SelectedIndex -= 1 113 | } 114 | if s.SelectedIndex < s.Vp.YOffset { 115 | s.Vp.YOffset -= 1 116 | } 117 | s.UpdateList() 118 | return s, nil 119 | } 120 | } 121 | return s, nil 122 | } 123 | 124 | func (s *ContainerList) View() string { 125 | if s.Err != nil { 126 | return styles.PageStyle().Render(lipgloss.Place(s.Width-2, s.Height, lipgloss.Center, lipgloss.Center, "Error: "+s.Err.Error())) 127 | } 128 | 129 | if len(s.Items) == 0 { 130 | return styles.PageStyle().Render(lipgloss.Place(s.Width-2, s.Height, lipgloss.Center, lipgloss.Center, "No Containers Found!")) 131 | } 132 | 133 | style := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(colors.Load().FocusedBorder) 134 | 135 | s.UpdateList() 136 | 137 | return lipgloss.JoinVertical(lipgloss.Center, 138 | style.Render(s.Ti.View()), 139 | s.Vp.View()) 140 | } 141 | 142 | func (s *ContainerList) UpdateList() { 143 | w := (s.Width-2)/9 - 1 144 | 145 | text := lipgloss.NewStyle().Bold(true).Render(docker.ContainerHeaders(w)+"\n") + "\n" 146 | 147 | for k, v := range s.FilteredItems { 148 | line := docker.ContainerFormattedSummary(v, w) 149 | 150 | if k == s.SelectedIndex { 151 | line = styles.SelectedStyle().Render(line) 152 | } else { 153 | line = styles.TextStyle().Render(line) 154 | } 155 | 156 | text += line + "\n" 157 | } 158 | 159 | s.Vp.SetContent(text) 160 | } 161 | 162 | func (s *ContainerList) Filter(val string) { 163 | w := (s.Width-2)/9 - 1 164 | 165 | formatted := make([]string, len(s.Items)) 166 | originals := make([]container.Summary, len(s.Items)) 167 | 168 | for i, v := range s.Items { 169 | str := docker.ContainerFormattedSummary(v, w) 170 | formatted[i] = str 171 | originals[i] = v 172 | } 173 | 174 | ranked := fuzzy.RankFindFold(val, formatted) 175 | sort.Sort(ranked) 176 | 177 | result := make([]container.Summary, len(ranked)) 178 | for i, r := range ranked { 179 | result[i] = originals[r.OriginalIndex] 180 | } 181 | 182 | s.FilteredItems = result 183 | 184 | if len(s.FilteredItems) <= s.SelectedIndex { 185 | s.SelectedIndex = len(s.FilteredItems) - 1 186 | } 187 | } 188 | 189 | func (s *ContainerList) GetCurrentItem() container.Summary { 190 | return s.FilteredItems[s.SelectedIndex] 191 | } 192 | -------------------------------------------------------------------------------- /internal/models/images/list.go: -------------------------------------------------------------------------------- 1 | package images 2 | 3 | import ( 4 | "sort" 5 | "time" 6 | 7 | "github.com/NucleoFusion/cruise/internal/colors" 8 | "github.com/NucleoFusion/cruise/internal/config" 9 | "github.com/NucleoFusion/cruise/internal/docker" 10 | "github.com/NucleoFusion/cruise/internal/messages" 11 | "github.com/NucleoFusion/cruise/internal/styles" 12 | "github.com/NucleoFusion/cruise/internal/utils" 13 | "github.com/charmbracelet/bubbles/textinput" 14 | "github.com/charmbracelet/bubbles/viewport" 15 | tea "github.com/charmbracelet/bubbletea" 16 | "github.com/charmbracelet/lipgloss" 17 | "github.com/docker/docker/api/types/image" 18 | "github.com/lithammer/fuzzysearch/fuzzy" 19 | ) 20 | 21 | type ImageList struct { 22 | Width int 23 | Height int 24 | ImageMap map[string]image.Summary 25 | Items []string 26 | FilteredItems []string 27 | SelectedIndex int 28 | Ti textinput.Model 29 | Vp viewport.Model 30 | } 31 | 32 | func NewImageList(w int, h int) *ImageList { 33 | ti := textinput.New() 34 | ti.Width = w - 12 35 | ti.Prompt = " Search: " 36 | ti.Placeholder = "Press '/' to search..." 37 | 38 | ti.PromptStyle = lipgloss.NewStyle().Foreground(colors.Load().FocusedBorder) 39 | ti.PlaceholderStyle = lipgloss.NewStyle().Foreground(colors.Load().PlaceholderText) 40 | ti.TextStyle = styles.TextStyle() 41 | 42 | vp := viewport.New(w, h-3) 43 | vp.Style = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(colors.Load().FocusedBorder). 44 | Padding(1).Foreground(colors.Load().Text) 45 | 46 | return &ImageList{ 47 | Width: w, 48 | Height: h, 49 | Ti: ti, 50 | SelectedIndex: 0, 51 | Vp: vp, 52 | ImageMap: make(map[string]image.Summary), 53 | } 54 | } 55 | 56 | func (s *ImageList) Init() tea.Cmd { 57 | return tea.Tick(0, func(_ time.Time) tea.Msg { 58 | images, err := docker.GetImages() 59 | 60 | m := make(map[string]image.Summary) 61 | for _, v := range images { 62 | m[v.ID] = v 63 | } 64 | 65 | if err != nil { 66 | return utils.ReturnError("Images Page", "Error Querying Images", err) 67 | } 68 | return messages.ImagesReadyMsg{Map: m} 69 | }) 70 | } 71 | 72 | func (s *ImageList) Update(msg tea.Msg) (*ImageList, tea.Cmd) { 73 | switch msg := msg.(type) { 74 | case messages.ImagesReadyMsg: 75 | s.ImageMap = msg.Map 76 | 77 | items := make([]string, 0, len(msg.Map)) 78 | for k := range s.ImageMap { 79 | items = append(items, k) 80 | } 81 | 82 | sort.Strings(items) 83 | 84 | s.Items = items 85 | s.FilteredItems = items 86 | 87 | return s, nil 88 | 89 | case messages.UpdateImagesMsg: 90 | items := make([]string, 0, len(msg.Items)) 91 | for _, v := range msg.Items { 92 | s.ImageMap[v.ID] = v 93 | items = append(items, v.ID) 94 | } 95 | 96 | sort.Strings(items) 97 | 98 | s.Items = items 99 | return s, nil 100 | 101 | case tea.KeyMsg: 102 | if s.Ti.Focused() { 103 | if msg.String() == config.Cfg.Keybinds.Global.UnfocusSearch { 104 | s.Ti.Blur() 105 | return s, nil 106 | } 107 | var cmd tea.Cmd 108 | s.Ti, cmd = s.Ti.Update(msg) 109 | s.Filter(s.Ti.Value()) 110 | s.UpdateList() 111 | return s, cmd 112 | } 113 | switch msg.String() { 114 | case config.Cfg.Keybinds.Global.FocusSearch: 115 | s.Ti.Focus() 116 | return s, nil 117 | case config.Cfg.Keybinds.Global.ListDown: 118 | if len(s.FilteredItems)-1 > s.SelectedIndex { 119 | s.SelectedIndex += 1 120 | } 121 | if s.SelectedIndex > s.Vp.Height+s.Vp.YOffset-7 { // -2 for border and sosething else, idk breaks otherwise 122 | s.Vp.YOffset += 1 123 | } 124 | s.UpdateList() 125 | return s, nil 126 | case config.Cfg.Keybinds.Global.ListUp: 127 | if 0 < s.SelectedIndex { 128 | s.SelectedIndex -= 1 129 | } 130 | if s.SelectedIndex < s.Vp.YOffset { 131 | s.Vp.YOffset -= 1 132 | } 133 | s.UpdateList() 134 | return s, nil 135 | } 136 | } 137 | return s, nil 138 | } 139 | 140 | func (s *ImageList) View() string { 141 | if len(s.Items) == 0 { 142 | return lipgloss.Place(s.Width-2, s.Height, lipgloss.Center, lipgloss.Center, "No Containers Found!") 143 | } 144 | 145 | style := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(colors.Load().FocusedBorder) 146 | 147 | s.UpdateList() 148 | 149 | return lipgloss.JoinVertical(lipgloss.Center, 150 | style.Render(s.Ti.View()), 151 | s.Vp.View()) 152 | } 153 | 154 | func (s *ImageList) UpdateList() { 155 | w := (s.Width-2)/9 - 1 156 | 157 | text := lipgloss.NewStyle().Bold(true).Render(docker.ImagesHeaders(w)+"\n") + "\n" 158 | 159 | for k, v := range s.FilteredItems { 160 | line := docker.ImagesFormattedSummary(s.ImageMap[v], w) 161 | 162 | if k == s.SelectedIndex { 163 | line = lipgloss.NewStyle().Background(colors.Load().MenuSelectedBg).Foreground(colors.Load().MenuSelectedText).Render(line) 164 | } else { 165 | line = styles.TextStyle().Render(line) 166 | } 167 | 168 | text += line + "\n" 169 | } 170 | 171 | s.Vp.SetContent(text) 172 | } 173 | 174 | func (s *ImageList) Filter(val string) { 175 | w := (s.Width-2)/9 - 1 176 | 177 | formatted := make([]string, len(s.Items)) 178 | originals := make([]image.Summary, len(s.Items)) 179 | 180 | for i, v := range s.Items { 181 | str := docker.ImagesFormattedSummary(s.ImageMap[v], w) 182 | formatted[i] = str 183 | originals[i] = s.ImageMap[v] 184 | } 185 | 186 | ranked := fuzzy.RankFindFold(val, formatted) 187 | sort.Sort(ranked) 188 | 189 | result := make([]string, len(ranked)) 190 | for i, r := range ranked { 191 | result[i] = originals[r.OriginalIndex].ID 192 | } 193 | 194 | s.FilteredItems = result 195 | 196 | if len(s.FilteredItems) <= s.SelectedIndex { 197 | s.SelectedIndex = len(s.FilteredItems) - 1 198 | } 199 | } 200 | 201 | func (s *ImageList) GetCurrentItem() image.Summary { 202 | return s.ImageMap[s.FilteredItems[s.SelectedIndex]] 203 | } 204 | -------------------------------------------------------------------------------- /internal/models/monitoring/monitoring.go: -------------------------------------------------------------------------------- 1 | package monitoring 2 | 3 | import ( 4 | "context" 5 | "sort" 6 | "strings" 7 | "time" 8 | 9 | "github.com/NucleoFusion/cruise/internal/colors" 10 | "github.com/NucleoFusion/cruise/internal/docker" 11 | "github.com/NucleoFusion/cruise/internal/keymap" 12 | "github.com/NucleoFusion/cruise/internal/messages" 13 | styledhelp "github.com/NucleoFusion/cruise/internal/models/help" 14 | "github.com/NucleoFusion/cruise/internal/styles" 15 | "github.com/NucleoFusion/cruise/internal/utils" 16 | "github.com/charmbracelet/bubbles/key" 17 | "github.com/charmbracelet/bubbles/textinput" 18 | "github.com/charmbracelet/bubbles/viewport" 19 | tea "github.com/charmbracelet/bubbletea" 20 | "github.com/charmbracelet/lipgloss" 21 | "github.com/docker/docker/api/types/events" 22 | "github.com/lithammer/fuzzysearch/fuzzy" 23 | ) 24 | 25 | type LogStreamer struct { 26 | ctx context.Context 27 | cancel context.CancelFunc 28 | lines chan string 29 | } 30 | 31 | type Monitoring struct { 32 | Width int 33 | Height int 34 | Vp viewport.Model 35 | Ti textinput.Model 36 | Keymap keymap.MonitorMap 37 | Help styledhelp.StyledHelp 38 | Events []*events.Message 39 | Filtered []*events.Message 40 | EventChan <-chan *events.Message 41 | ErrChan <-chan error 42 | IsLoading bool 43 | Length int 44 | } 45 | 46 | func NewMonitoring(w int, h int) *Monitoring { 47 | vp := viewport.New(w-2, h-8-strings.Count(styles.MonitoringText, "\n")) 48 | vp.Style = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(colors.Load().FocusedBorder). 49 | Padding(1).Foreground(colors.Load().Text) 50 | 51 | ti := textinput.New() 52 | ti.Width = w - 14 53 | ti.Prompt = " Search: " 54 | ti.Placeholder = "Press '/' to search..." 55 | 56 | ti.PromptStyle = lipgloss.NewStyle().Foreground(colors.Load().FocusedBorder) 57 | ti.PlaceholderStyle = lipgloss.NewStyle().Foreground(colors.Load().PlaceholderText) 58 | ti.TextStyle = styles.TextStyle() 59 | 60 | eventChan, errChan := docker.RecentEventStream(h/3 - 6) 61 | 62 | return &Monitoring{ 63 | Width: w, 64 | Height: h, 65 | Help: styledhelp.NewStyledHelp(keymap.NewMonitorMap().Bindings(), w-2), 66 | Keymap: keymap.NewMonitorMap(), 67 | Vp: vp, 68 | Ti: ti, 69 | Length: h - 6 - strings.Count(styles.MonitoringText, "\n"), // -6 for styled help and ti 70 | EventChan: eventChan, 71 | ErrChan: errChan, 72 | } 73 | } 74 | 75 | func (s *Monitoring) Init() tea.Cmd { 76 | return tea.Batch(s.Sub()) 77 | } 78 | 79 | func (s *Monitoring) Sub() tea.Cmd { 80 | return tea.Every(2*time.Second, func(_ time.Time) tea.Msg { 81 | return s.PollEvents()() 82 | }) 83 | } 84 | 85 | func (s *Monitoring) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 86 | switch msg := msg.(type) { 87 | case messages.NewEvents: 88 | s.Events = append(s.Events, msg.Events...) 89 | 90 | s.Filtered = s.Events 91 | 92 | if s.Ti.Value() != "" { 93 | s.Filter(s.Ti.Value()) 94 | } 95 | 96 | if s.IsLoading { 97 | s.IsLoading = false 98 | } 99 | 100 | s.Vp.SetYOffset(len(s.Events)) 101 | 102 | return s, s.Sub() 103 | case tea.KeyMsg: 104 | if s.Ti.Focused() { 105 | if key.Matches(msg, s.Keymap.ExitSearch) { 106 | s.Ti.Blur() 107 | return s, nil 108 | } 109 | var cmd tea.Cmd 110 | s.Ti, cmd = s.Ti.Update(msg) 111 | s.Filter(s.Ti.Value()) 112 | return s, cmd 113 | } 114 | switch { 115 | case key.Matches(msg, keymap.QuickQuitKey()): 116 | return s, tea.Quit 117 | case key.Matches(msg, s.Keymap.Search): 118 | s.Ti.Focus() 119 | return s, nil 120 | case key.Matches(msg, s.Keymap.Export): 121 | arr := make([]string, 0) 122 | for _, v := range s.Events { 123 | arr = append(arr, docker.FormatDockerEventVerbose(*v)) 124 | } 125 | 126 | err := docker.Export(arr, "monitoring") 127 | if err != nil { 128 | return s, utils.ReturnError("Monitoring", "Error Exporting", err) 129 | } 130 | 131 | return s, utils.ReturnMsg("Monitoring", "Exported Successfully", "exported events to export dir.") 132 | } 133 | } 134 | return s, nil 135 | } 136 | 137 | func (s *Monitoring) View() string { 138 | if s.IsLoading { 139 | s.Vp.SetContent("Loading...") 140 | } else { 141 | s.Vp.SetContent(s.FormattedView()) 142 | } 143 | 144 | return styles.SceneStyle().Render( 145 | lipgloss.JoinVertical(lipgloss.Center, 146 | styles.TextStyle().Padding(1, 0).Render(styles.MonitoringText), 147 | styles.PageStyle().Render(s.Ti.View()), 148 | s.Vp.View(), s.Help.View())) 149 | } 150 | 151 | func (s *Monitoring) FormattedView() string { 152 | if s.IsLoading { 153 | return "Loading Logs....\n" 154 | } 155 | 156 | if len(s.Events) == 0 { 157 | return "No Logs yet, waiting....\n" 158 | } 159 | 160 | text := "" 161 | events := s.Filtered 162 | for _, msg := range events { 163 | text += docker.FormatDockerEventVerbose(*msg) + "\n" 164 | } 165 | 166 | return text 167 | } 168 | 169 | func (s *Monitoring) PollEvents() tea.Cmd { 170 | return func() tea.Msg { 171 | evs := make([]*events.Message, 0, s.Length) 172 | 173 | drain: 174 | for { 175 | select { 176 | case err := <-s.ErrChan: 177 | return utils.ReturnError("Monitoring Page", "Error Querying Events", err) 178 | default: 179 | for i := 0; i < s.Length; i++ { 180 | select { 181 | case ev := <-s.EventChan: 182 | evs = append(evs, ev) 183 | default: 184 | return messages.NewEvents{ 185 | Events: evs, 186 | } 187 | } 188 | } 189 | break drain 190 | } 191 | } 192 | 193 | return messages.NewEvents{ 194 | Events: evs, 195 | } 196 | } 197 | } 198 | 199 | func (s *Monitoring) Filter(val string) { 200 | formatted := make([]string, len(s.Events)) 201 | originals := make([]*events.Message, len(s.Events)) 202 | 203 | for i, v := range s.Events { 204 | str := docker.FormatDockerEventVerbose(*v) 205 | formatted[i] = str 206 | originals[i] = v 207 | } 208 | 209 | ranked := fuzzy.RankFindFold(val, formatted) 210 | sort.Sort(ranked) 211 | 212 | result := make([]*events.Message, len(ranked)) 213 | for i, r := range ranked { 214 | result[i] = originals[r.OriginalIndex] 215 | } 216 | 217 | s.Filtered = result 218 | } 219 | -------------------------------------------------------------------------------- /internal/models/images/images.go: -------------------------------------------------------------------------------- 1 | package images 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/NucleoFusion/cruise/internal/colors" 9 | "github.com/NucleoFusion/cruise/internal/docker" 10 | "github.com/NucleoFusion/cruise/internal/keymap" 11 | "github.com/NucleoFusion/cruise/internal/messages" 12 | styledhelp "github.com/NucleoFusion/cruise/internal/models/help" 13 | "github.com/NucleoFusion/cruise/internal/styles" 14 | "github.com/NucleoFusion/cruise/internal/utils" 15 | "github.com/charmbracelet/bubbles/key" 16 | "github.com/charmbracelet/bubbles/viewport" 17 | tea "github.com/charmbracelet/bubbletea" 18 | "github.com/charmbracelet/lipgloss" 19 | ) 20 | 21 | type Images struct { 22 | Width int 23 | Height int 24 | List *ImageList 25 | Keymap keymap.ImagesMap 26 | Vp viewport.Model 27 | Help styledhelp.StyledHelp 28 | ShowVp bool 29 | IsLoading bool 30 | } 31 | 32 | func NewImages(w int, h int) *Images { 33 | vp := viewport.New(w/3, h/2) 34 | vp.Style = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(colors.Load().FocusedBorder). 35 | Padding(1).Foreground(colors.Load().Text) 36 | 37 | return &Images{ 38 | Width: w, 39 | Height: h, 40 | IsLoading: true, 41 | List: NewImageList(w-2, h-5-strings.Count(styles.ImagesText, "\n")), //h-5 to account for styled help and title padding 42 | Keymap: keymap.NewImagesMap(), 43 | Help: styledhelp.NewStyledHelp(keymap.NewImagesMap().Bindings(), w-2), 44 | Vp: vp, 45 | ShowVp: false, 46 | } 47 | } 48 | 49 | func (s *Images) Init() tea.Cmd { 50 | return s.List.Init() 51 | } 52 | 53 | func (s *Images) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 54 | switch msg := msg.(type) { 55 | case messages.ImagesReadyMsg: 56 | s.IsLoading = false 57 | 58 | var cmd tea.Cmd 59 | s.List, cmd = s.List.Update(msg) 60 | return s, cmd 61 | case messages.UpdateImagesMsg: 62 | var cmd tea.Cmd 63 | s.List, cmd = s.List.Update(msg) 64 | return s, cmd 65 | case tea.KeyMsg: 66 | if s.List.Ti.Focused() { 67 | var cmd tea.Cmd 68 | s.List, cmd = s.List.Update(msg) 69 | return s, cmd 70 | } else if s.ShowVp { 71 | if key.Matches(msg, key.NewBinding(key.WithKeys("esc"))) { 72 | s.ShowVp = false 73 | return s, nil 74 | } 75 | var cmd tea.Cmd 76 | s.Vp, cmd = s.Vp.Update(msg) 77 | return s, cmd 78 | } 79 | switch { 80 | case key.Matches(msg, keymap.QuickQuitKey()): 81 | return s, tea.Quit 82 | case key.Matches(msg, s.Keymap.Remove): 83 | err := docker.RemoveImage(s.List.GetCurrentItem().ID) 84 | if err != nil { 85 | return s, utils.ReturnError("Images Page", "Error Removing Image", err) 86 | } 87 | return s, tea.Batch(s.UpdateImages(), utils.ReturnMsg("Images Page", "Removing Image", 88 | fmt.Sprintf("Successfully Removed Image w/ ID %s", s.List.GetCurrentItem().ID))) 89 | case key.Matches(msg, s.Keymap.Pull): 90 | curr := s.List.GetCurrentItem() 91 | img := curr.ID // Accurately get the image name 92 | if len(curr.RepoTags) > 0 && curr.RepoTags[0] != ":" { 93 | img = curr.RepoTags[0] 94 | } else if len(curr.RepoDigests) > 0 { 95 | img = curr.RepoDigests[0] 96 | } 97 | 98 | err := docker.PullImage(img) 99 | if err != nil { 100 | return s, utils.ReturnError("Images Page", "Error Pulling Image", err) 101 | } 102 | return s, tea.Batch(s.UpdateImages(), utils.ReturnMsg("Images Page", "Pulling Image", 103 | fmt.Sprintf("Successfully Pulled Image w/ ID %s", s.List.GetCurrentItem().ID))) 104 | case key.Matches(msg, s.Keymap.Push): 105 | curr := s.List.GetCurrentItem() 106 | img := curr.ID // Accurately get the image name 107 | if len(curr.RepoTags) > 0 && curr.RepoTags[0] != ":" { 108 | img = curr.RepoTags[0] 109 | } else if len(curr.RepoDigests) > 0 { 110 | img = curr.RepoDigests[0] 111 | } 112 | 113 | err := docker.PushImage(img) 114 | if err != nil { 115 | return s, utils.ReturnError("Images Page", "Error Pushing Image", err) 116 | } 117 | return s, tea.Batch(s.UpdateImages(), utils.ReturnMsg("Images Page", "Pushing Image", 118 | fmt.Sprintf("Successfully Pushed Image w/ ID %s", s.List.GetCurrentItem().ID))) 119 | case key.Matches(msg, s.Keymap.Prune): 120 | err := docker.PruneImages() 121 | if err != nil { 122 | return s, utils.ReturnError("Images Page", "Error Pruning Image", err) 123 | } 124 | return s, tea.Batch(s.UpdateImages(), utils.ReturnMsg("Images Page", "Pruning Image", "Successfully Pruned Images")) 125 | case key.Matches(msg, s.Keymap.Sync): 126 | return s, s.UpdateImages() 127 | case key.Matches(msg, s.Keymap.Layers): 128 | curr := s.List.GetCurrentItem() 129 | img := curr.ID // Accurately get the image name 130 | if len(curr.RepoTags) > 0 && curr.RepoTags[0] != ":" { 131 | img = curr.RepoTags[0] 132 | } else if len(curr.RepoDigests) > 0 { 133 | img = curr.RepoDigests[0] 134 | } 135 | 136 | text, err := docker.ImageHistory(img) 137 | if err != nil { 138 | return s, utils.ReturnError("Images Page", "Error Querying Image Layers", err) 139 | } 140 | 141 | s.Vp.SetContent(text) 142 | 143 | s.ShowVp = true 144 | return s, nil 145 | } 146 | } 147 | 148 | var cmd tea.Cmd 149 | s.List, cmd = s.List.Update(msg) 150 | return s, cmd 151 | } 152 | 153 | func (s *Images) View() string { 154 | if s.ShowVp { 155 | return lipgloss.Place(s.Width, s.Height, lipgloss.Center, lipgloss.Center, s.Vp.View()) 156 | } 157 | 158 | return styles.SceneStyle().Render( 159 | lipgloss.JoinVertical(lipgloss.Center, 160 | styles.TextStyle().Padding(1, 0).Render(styles.ImagesText), s.GetListText(), s.Help.View())) 161 | } 162 | 163 | func (s *Images) GetListText() string { 164 | if s.IsLoading { 165 | return lipgloss.Place(s.Width-2, s.Height-4-strings.Count(styles.ImagesText, "\n"), 166 | lipgloss.Center, lipgloss.Top, "Loading...") 167 | } 168 | 169 | return lipgloss.NewStyle().Render(s.List.View()) 170 | } 171 | 172 | func (s *Images) UpdateImages() tea.Cmd { 173 | return tea.Tick(0, func(_ time.Time) tea.Msg { 174 | images, err := docker.GetImages() 175 | if err != nil { 176 | return utils.ReturnError("Images Page", "Error Querying Images", err) 177 | } 178 | return messages.UpdateImagesMsg{Items: images} 179 | }) 180 | } 181 | -------------------------------------------------------------------------------- /internal/models/volumes/details.go: -------------------------------------------------------------------------------- 1 | package volumes 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/NucleoFusion/cruise/internal/docker" 8 | "github.com/NucleoFusion/cruise/internal/styles" 9 | "github.com/NucleoFusion/cruise/internal/utils" 10 | "github.com/charmbracelet/bubbles/viewport" 11 | tea "github.com/charmbracelet/bubbletea" 12 | "github.com/charmbracelet/lipgloss" 13 | "github.com/docker/docker/api/types/volume" 14 | ) 15 | 16 | type VolumeDetail struct { 17 | Width int 18 | Height int 19 | Volume volume.Volume 20 | LabelsVp viewport.Model 21 | DashVp viewport.Model 22 | UsageVp viewport.Model 23 | StatusVp viewport.Model 24 | OptsVp viewport.Model 25 | } 26 | 27 | func NewDetail(w int, h int, vol volume.Volume) *VolumeDetail { 28 | v, err := docker.InspectVolume(vol.Name) 29 | if err != nil { 30 | v = vol 31 | } 32 | 33 | labels := make([]string, 0, len(v.Labels)) 34 | for k := range v.Labels { 35 | labels = append(labels, k) 36 | } 37 | 38 | opts := make([]string, 0, len(v.Options)) 39 | for k := range v.Options { 40 | opts = append(opts, k) 41 | } 42 | 43 | status := make([]string, 0, len(v.Status)) 44 | for k := range v.Status { 45 | opts = append(opts, k) 46 | } 47 | 48 | // Label VP 49 | lvp := viewport.New((w-2)/3, h-h*3/5) 50 | lvp.Style = styles.PageStyle().Padding(1, 2) 51 | lvp.SetContent(getLabelView(v, labels, (w-2)/3-4)) 52 | 53 | // Dash VP 54 | dvp := viewport.New((w-2)/3, h*3/5) 55 | dvp.Style = styles.PageStyle().Padding(1, 2) 56 | dvp.SetContent(getDashboardView(v, (w-2)/3-4)) 57 | 58 | // Status VP 59 | svp := viewport.New(w-2-(w-2)*2/3, h) 60 | svp.Style = styles.PageStyle().Padding(1, 2) 61 | svp.SetContent(getStatusView(v, status, w-2-(w-2)*2/3-4)) 62 | 63 | // Usage VP 64 | uvp := viewport.New((w-2)/3, h/2) 65 | uvp.Style = styles.PageStyle().Padding(1, 2) 66 | uvp.SetContent(getUsageView(v, (w-2)/3-4)) 67 | 68 | // Options VP 69 | ovp := viewport.New((w-2)/3, h-h/2) 70 | ovp.Style = styles.PageStyle().Padding(1, 2) 71 | ovp.SetContent(getOptionsView(v, opts, (w-2)/3-4)) 72 | 73 | return &VolumeDetail{ 74 | Width: w, 75 | Height: h, 76 | Volume: v, 77 | LabelsVp: lvp, 78 | DashVp: dvp, 79 | StatusVp: svp, 80 | UsageVp: uvp, 81 | OptsVp: ovp, 82 | } 83 | } 84 | 85 | func (s *VolumeDetail) Init() tea.Cmd { 86 | return nil 87 | } 88 | 89 | func (s *VolumeDetail) Update(msg tea.Msg) (*VolumeDetail, tea.Cmd) { 90 | return s, nil 91 | } 92 | 93 | func (s *VolumeDetail) View() string { 94 | return lipgloss.JoinHorizontal(lipgloss.Center, lipgloss.JoinVertical(lipgloss.Center, s.DashVp.View(), s.LabelsVp.View()), 95 | lipgloss.JoinVertical(lipgloss.Center, s.UsageVp.View(), s.OptsVp.View()), s.StatusVp.View()) 96 | } 97 | 98 | func getDashboardView(vol volume.Volume, w int) string { 99 | text := fmt.Sprintf("%s %s \n\n%s %s \n\n%s %s \n\n%s %s \n\n%s %s", 100 | styles.DetailKeyStyle().Render(" Name: "), styles.TextStyle().Render(vol.Name), 101 | styles.DetailKeyStyle().Render(" Scope: "), styles.TextStyle().Render(utils.Shorten(vol.Scope, w-10)), 102 | styles.DetailKeyStyle().Render(" Driver: "), styles.TextStyle().Render(vol.Driver), 103 | styles.DetailKeyStyle().Render(" MountPoint: "), styles.TextStyle().Render(utils.Shorten(vol.Mountpoint, w-10)), 104 | styles.DetailKeyStyle().Render(" Created: "), styles.TextStyle().Render(utils.Shorten(vol.CreatedAt, w-10))) 105 | 106 | return lipgloss.JoinVertical(lipgloss.Left, lipgloss.PlaceHorizontal(w, lipgloss.Center, styles.TitleStyle().Render(" Volume Details ")), "\n\n", text) 107 | } 108 | 109 | func getUsageView(vol volume.Volume, w int) string { 110 | if vol.UsageData == nil { 111 | return lipgloss.JoinVertical(lipgloss.Center, lipgloss.PlaceHorizontal(w, lipgloss.Center, styles.TitleStyle().Render(" Usage ")), "\n\n", "NA") 112 | } 113 | 114 | text := fmt.Sprintf("%s %s \n\n%s %sKb ", 115 | styles.DetailKeyStyle().Render(" RefCount: "), styles.TextStyle().Render(fmt.Sprintf("%d", vol.UsageData.RefCount)), 116 | styles.DetailKeyStyle().Render(" Size: "), styles.TextStyle().Render(fmt.Sprintf("%d", vol.UsageData.Size/1024))) 117 | return lipgloss.JoinVertical(lipgloss.Left, lipgloss.PlaceHorizontal(w, lipgloss.Center, styles.TitleStyle().Render(" Usage ")), "\n\n", text) 118 | } 119 | 120 | func getLabelView(vol volume.Volume, labels []string, w int) string { 121 | text := "" 122 | 123 | if len(labels) == 0 { 124 | return lipgloss.JoinVertical(lipgloss.Center, lipgloss.PlaceHorizontal(w, lipgloss.Center, styles.TitleStyle().Render("Labels")), "\n\n", "No Labels Found") 125 | } 126 | 127 | for _, v := range labels { 128 | text += fmt.Sprintf("%s %s\n\n", styles.DetailKeyStyle().Render(fmt.Sprintf(" %s: ", utils.Shorten(strings.TrimPrefix(v, "com.docker."), 25))), 129 | styles.TextStyle().Render(utils.Shorten(vol.Labels[v], w-8-len(v)))) 130 | } 131 | 132 | return lipgloss.JoinVertical(lipgloss.Left, lipgloss.PlaceHorizontal(w, lipgloss.Center, styles.TitleStyle().Render(" Labels ")), "\n\n", text) 133 | } 134 | 135 | func getStatusView(vol volume.Volume, opts []string, w int) string { 136 | text := "" 137 | 138 | if len(opts) == 0 { 139 | return lipgloss.JoinVertical(lipgloss.Center, lipgloss.PlaceHorizontal(w, lipgloss.Center, styles.TitleStyle().Render(" Status ")), "\n\n", "No Status Found") 140 | } 141 | 142 | for _, v := range opts { 143 | text += fmt.Sprintf("%s %s\n\n", styles.DetailKeyStyle().Render(fmt.Sprintf(" %s: ", v)), 144 | styles.TextStyle().Render(utils.Shorten(vol.Options[v], w-8-len(v)))) 145 | } 146 | 147 | return lipgloss.JoinVertical(lipgloss.Left, lipgloss.PlaceHorizontal(w, lipgloss.Center, styles.TitleStyle().Render(" Status ")), "\n\n", text) 148 | } 149 | 150 | func getOptionsView(vol volume.Volume, opts []string, w int) string { 151 | text := "" 152 | 153 | if len(opts) == 0 { 154 | return lipgloss.JoinVertical(lipgloss.Center, lipgloss.PlaceHorizontal(w, lipgloss.Center, styles.TitleStyle().Render(" Options ")), "\n\n", "No Options Found") 155 | } 156 | 157 | for _, v := range opts { 158 | text += fmt.Sprintf("%s %s\n\n", styles.DetailKeyStyle().Render(fmt.Sprintf(" %s: ", utils.Shorten(v, 25))), 159 | styles.TextStyle().Render(utils.Shorten(vol.Options[v], w-8-len(v)))) 160 | } 161 | 162 | return lipgloss.JoinVertical(lipgloss.Left, lipgloss.PlaceHorizontal(w, lipgloss.Center, styles.TitleStyle().Render(" Options ")), "\n\n", "Check") 163 | } 164 | -------------------------------------------------------------------------------- /internal/models/networks/detail.go: -------------------------------------------------------------------------------- 1 | package networks 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/NucleoFusion/cruise/internal/styles" 9 | "github.com/NucleoFusion/cruise/internal/utils" 10 | "github.com/charmbracelet/bubbles/viewport" 11 | tea "github.com/charmbracelet/bubbletea" 12 | "github.com/charmbracelet/lipgloss" 13 | "github.com/docker/docker/api/types/network" 14 | ) 15 | 16 | type NetworkDetail struct { 17 | Width int 18 | Height int 19 | Network network.Summary 20 | LabelsVp viewport.Model 21 | DashVp viewport.Model 22 | ContVp viewport.Model 23 | IPAMVp viewport.Model 24 | OptsVp viewport.Model 25 | } 26 | 27 | func NewDetail(w int, h int, ntw network.Summary) *NetworkDetail { 28 | labels := make([]string, 0, len(ntw.Labels)) 29 | for k := range ntw.Labels { 30 | labels = append(labels, k) 31 | } 32 | 33 | containers := make([]string, 0, len(ntw.Containers)) 34 | for k := range ntw.Containers { 35 | containers = append(containers, k) 36 | } 37 | 38 | ipamOpts := make([]string, 0, len(ntw.IPAM.Options)) 39 | for k := range ntw.IPAM.Options { 40 | ipamOpts = append(ipamOpts, k) 41 | } 42 | 43 | opts := make([]string, 0, len(ntw.Options)) 44 | for k := range ntw.Options { 45 | opts = append(opts, k) 46 | } 47 | 48 | // Label VP 49 | lvp := viewport.New((w-2)/3, h/5*2) 50 | lvp.Style = styles.PageStyle().Padding(1, 2) 51 | lvp.SetContent(getLabelView(ntw, labels, (w-2)/3-4)) 52 | 53 | // Dash VP 54 | dvp := viewport.New((w-2)/3, h-h/5*2) 55 | dvp.Style = styles.PageStyle().Padding(1, 2) 56 | dvp.SetContent(getDashboardView(ntw, (w-2)/3-4)) 57 | 58 | // Cont VP 59 | cvp := viewport.New((w-2)-(w-2)/3, h-h/2) 60 | cvp.Style = styles.PageStyle().Padding(1, 2) 61 | cvp.SetContent(getContainerView(ntw, containers, (w-2)-(w-2)/3-4)) 62 | 63 | // IPAM VP 64 | ivp := viewport.New((w-2)/3, h/2) 65 | ivp.Style = styles.PageStyle().Padding(1, 2) 66 | ivp.SetContent(getIPAMView(ntw, ipamOpts, (w-2)/3-4)) 67 | 68 | // Options VP 69 | ovp := viewport.New((w-2)-(w-2)/3*2, h/2) 70 | ovp.Style = styles.PageStyle().Padding(1, 2) 71 | ovp.SetContent(getOptionsView(ntw, opts, (w-2)-(w-2)*2/3-4)) 72 | 73 | return &NetworkDetail{ 74 | Width: w, 75 | Height: h, 76 | Network: ntw, 77 | LabelsVp: lvp, 78 | DashVp: dvp, 79 | ContVp: cvp, 80 | IPAMVp: ivp, 81 | OptsVp: ovp, 82 | } 83 | } 84 | 85 | func (s *NetworkDetail) Init() tea.Cmd { 86 | return nil 87 | } 88 | 89 | func (s *NetworkDetail) Update(msg tea.Msg) (*NetworkDetail, tea.Cmd) { 90 | return s, nil 91 | } 92 | 93 | func (s *NetworkDetail) View() string { 94 | return lipgloss.JoinHorizontal(lipgloss.Center, lipgloss.JoinVertical(lipgloss.Center, s.DashVp.View(), s.LabelsVp.View()), 95 | lipgloss.JoinVertical(lipgloss.Center, s.ContVp.View(), 96 | lipgloss.JoinHorizontal(lipgloss.Center, s.OptsVp.View(), s.IPAMVp.View()))) 97 | } 98 | 99 | func getDashboardView(ntw network.Summary, w int) string { 100 | intrn := "✘" 101 | if ntw.Internal { 102 | intrn = "✔" 103 | } 104 | ingr := "✘" 105 | if ntw.Ingress { 106 | intrn = "✔" 107 | } 108 | text := fmt.Sprintf("%s %s \n\n%s %s \n\n%s %s \n\n%s %s \n\n%s %s \n\n%s %s \n\n%s %s", 109 | styles.DetailKeyStyle().Render(" Network: "), styles.TextStyle().Render(ntw.Name), 110 | styles.DetailKeyStyle().Render(" ID: "), styles.TextStyle().Render(utils.Shorten(ntw.ID, w-10)), 111 | styles.DetailKeyStyle().Render(" Created: "), styles.TextStyle().Render(utils.Shorten(ntw.Created.Format(time.DateOnly)+" "+ntw.Created.Format(time.Kitchen), w-15)), 112 | styles.DetailKeyStyle().Render(" Driver: "), styles.TextStyle().Render(ntw.Driver), 113 | styles.DetailKeyStyle().Render(" Scope: "), styles.TextStyle().Render(ntw.Scope), 114 | styles.DetailKeyStyle().Render(" Internal: "), styles.TextStyle().Render(intrn), 115 | styles.DetailKeyStyle().Render(" Ingress: "), styles.TextStyle().Render(ingr)) 116 | 117 | return lipgloss.JoinVertical(lipgloss.Left, lipgloss.PlaceHorizontal(w, lipgloss.Center, styles.TitleStyle().Render(" Network Details ")), "\n\n", text) 118 | } 119 | 120 | func getContainerView(ntw network.Summary, cntnrs []string, w int) string { 121 | if len(cntnrs) == 0 { 122 | return lipgloss.JoinVertical(lipgloss.Center, lipgloss.PlaceHorizontal(w, lipgloss.Center, styles.TitleStyle().Render(" Network Details ")), "\n\n", "No Connected Containers") 123 | } 124 | 125 | ln := w - 2 126 | text := lipgloss.NewStyle().Bold(true).Render(fmt.Sprintf("%-*s %-*s %-*s %-*s %-*s\n\n", 127 | ln/5, "ID", 128 | ln/5, "Name", 129 | ln/5, "MAC", 130 | ln/5, "IPv4", 131 | ln/5, "IPv6", 132 | )) 133 | 134 | for _, v := range cntnrs { 135 | text += fmt.Sprintf("%-*s %-*s %-*s %-*s %-*s\n\n", 136 | ln/5, ntw.Containers[v].EndpointID, 137 | ln/5, ntw.Containers[v].Name, 138 | ln/5, ntw.Containers[v].MacAddress, 139 | ln/5, ntw.Containers[v].IPv4Address, 140 | ln/5, ntw.Containers[v].IPv6Address, 141 | ) 142 | } 143 | 144 | return lipgloss.JoinVertical(lipgloss.Left, lipgloss.PlaceHorizontal(w, lipgloss.Center, styles.TitleStyle().Render(" Containers ")), "\n\n", text) 145 | } 146 | 147 | func getLabelView(ntw network.Summary, labels []string, w int) string { 148 | text := "" 149 | 150 | if len(labels) == 0 { 151 | return lipgloss.JoinVertical(lipgloss.Center, lipgloss.PlaceHorizontal(w, lipgloss.Center, styles.TitleStyle().Render("Labels")), "\n\n", "No Labels Found") 152 | } 153 | 154 | for _, v := range labels { 155 | text += fmt.Sprintf("%s %s\n\n", styles.DetailKeyStyle().Render(fmt.Sprintf(" %s: ", utils.Shorten(strings.TrimPrefix(v, "com.docker."), 25))), 156 | styles.TextStyle().Render(utils.Shorten(ntw.Labels[v], w-4-len(v)))) 157 | } 158 | 159 | return lipgloss.JoinVertical(lipgloss.Left, lipgloss.PlaceHorizontal(w, lipgloss.Center, styles.TitleStyle().Render(" Labels ")), "\n\n", text) 160 | } 161 | 162 | func getIPAMView(ntw network.Summary, opts []string, w int) string { 163 | text := fmt.Sprintf("%s %s\n\n", styles.DetailKeyStyle().Render(fmt.Sprintf(" %s: ", "Driver")), 164 | styles.TextStyle().Render(ntw.IPAM.Driver)) 165 | 166 | for _, v := range opts { 167 | text += fmt.Sprintf("%s %s\n\n", styles.DetailKeyStyle().Render(fmt.Sprintf(" %s: ", utils.Shorten(v, 25))), 168 | styles.TextStyle().Render(utils.Shorten(ntw.IPAM.Options[v], w-4-len(v)))) 169 | } 170 | 171 | return lipgloss.JoinVertical(lipgloss.Left, lipgloss.PlaceHorizontal(w, lipgloss.Center, styles.TitleStyle().Render(" IPAM ")), "\n\n", text) 172 | } 173 | 174 | func getOptionsView(ntw network.Summary, opts []string, w int) string { 175 | text := "" 176 | 177 | if len(opts) == 0 { 178 | return lipgloss.JoinVertical(lipgloss.Center, lipgloss.PlaceHorizontal(w, lipgloss.Center, styles.TitleStyle().Render(" Options ")), "\n\n", "No Options Found") 179 | } 180 | 181 | for _, v := range opts { 182 | text += fmt.Sprintf("%s %s\n\n", styles.DetailKeyStyle().Render(fmt.Sprintf(" %s: ", utils.Shorten(v, 25))), 183 | styles.TextStyle().Render(utils.Shorten(ntw.Options[v], w-4-len(v)))) 184 | } 185 | 186 | return lipgloss.JoinVertical(lipgloss.Left, lipgloss.PlaceHorizontal(w, lipgloss.Center, styles.TitleStyle().Render(" Options ")), "\n\n", "Check") 187 | } 188 | -------------------------------------------------------------------------------- /docs/.vitepress/cache/deps/vue.js: -------------------------------------------------------------------------------- 1 | import { 2 | BaseTransition, 3 | BaseTransitionPropsValidators, 4 | Comment, 5 | DeprecationTypes, 6 | EffectScope, 7 | ErrorCodes, 8 | ErrorTypeStrings, 9 | Fragment, 10 | KeepAlive, 11 | ReactiveEffect, 12 | Static, 13 | Suspense, 14 | Teleport, 15 | Text, 16 | TrackOpTypes, 17 | Transition, 18 | TransitionGroup, 19 | TriggerOpTypes, 20 | VueElement, 21 | assertNumber, 22 | callWithAsyncErrorHandling, 23 | callWithErrorHandling, 24 | camelize, 25 | capitalize, 26 | cloneVNode, 27 | compatUtils, 28 | compile, 29 | computed, 30 | createApp, 31 | createBaseVNode, 32 | createBlock, 33 | createCommentVNode, 34 | createElementBlock, 35 | createHydrationRenderer, 36 | createPropsRestProxy, 37 | createRenderer, 38 | createSSRApp, 39 | createSlots, 40 | createStaticVNode, 41 | createTextVNode, 42 | createVNode, 43 | customRef, 44 | defineAsyncComponent, 45 | defineComponent, 46 | defineCustomElement, 47 | defineEmits, 48 | defineExpose, 49 | defineModel, 50 | defineOptions, 51 | defineProps, 52 | defineSSRCustomElement, 53 | defineSlots, 54 | devtools, 55 | effect, 56 | effectScope, 57 | getCurrentInstance, 58 | getCurrentScope, 59 | getCurrentWatcher, 60 | getTransitionRawChildren, 61 | guardReactiveProps, 62 | h, 63 | handleError, 64 | hasInjectionContext, 65 | hydrate, 66 | hydrateOnIdle, 67 | hydrateOnInteraction, 68 | hydrateOnMediaQuery, 69 | hydrateOnVisible, 70 | initCustomFormatter, 71 | initDirectivesForSSR, 72 | inject, 73 | isMemoSame, 74 | isProxy, 75 | isReactive, 76 | isReadonly, 77 | isRef, 78 | isRuntimeOnly, 79 | isShallow, 80 | isVNode, 81 | markRaw, 82 | mergeDefaults, 83 | mergeModels, 84 | mergeProps, 85 | nextTick, 86 | normalizeClass, 87 | normalizeProps, 88 | normalizeStyle, 89 | onActivated, 90 | onBeforeMount, 91 | onBeforeUnmount, 92 | onBeforeUpdate, 93 | onDeactivated, 94 | onErrorCaptured, 95 | onMounted, 96 | onRenderTracked, 97 | onRenderTriggered, 98 | onScopeDispose, 99 | onServerPrefetch, 100 | onUnmounted, 101 | onUpdated, 102 | onWatcherCleanup, 103 | openBlock, 104 | popScopeId, 105 | provide, 106 | proxyRefs, 107 | pushScopeId, 108 | queuePostFlushCb, 109 | reactive, 110 | readonly, 111 | ref, 112 | registerRuntimeCompiler, 113 | render, 114 | renderList, 115 | renderSlot, 116 | resolveComponent, 117 | resolveDirective, 118 | resolveDynamicComponent, 119 | resolveFilter, 120 | resolveTransitionHooks, 121 | setBlockTracking, 122 | setDevtoolsHook, 123 | setTransitionHooks, 124 | shallowReactive, 125 | shallowReadonly, 126 | shallowRef, 127 | ssrContextKey, 128 | ssrUtils, 129 | stop, 130 | toDisplayString, 131 | toHandlerKey, 132 | toHandlers, 133 | toRaw, 134 | toRef, 135 | toRefs, 136 | toValue, 137 | transformVNodeArgs, 138 | triggerRef, 139 | unref, 140 | useAttrs, 141 | useCssModule, 142 | useCssVars, 143 | useHost, 144 | useId, 145 | useModel, 146 | useSSRContext, 147 | useShadowRoot, 148 | useSlots, 149 | useTemplateRef, 150 | useTransitionState, 151 | vModelCheckbox, 152 | vModelDynamic, 153 | vModelRadio, 154 | vModelSelect, 155 | vModelText, 156 | vShow, 157 | version, 158 | warn, 159 | watch, 160 | watchEffect, 161 | watchPostEffect, 162 | watchSyncEffect, 163 | withAsyncContext, 164 | withCtx, 165 | withDefaults, 166 | withDirectives, 167 | withKeys, 168 | withMemo, 169 | withModifiers, 170 | withScopeId 171 | } from "./chunk-EAEFJUV4.js"; 172 | export { 173 | BaseTransition, 174 | BaseTransitionPropsValidators, 175 | Comment, 176 | DeprecationTypes, 177 | EffectScope, 178 | ErrorCodes, 179 | ErrorTypeStrings, 180 | Fragment, 181 | KeepAlive, 182 | ReactiveEffect, 183 | Static, 184 | Suspense, 185 | Teleport, 186 | Text, 187 | TrackOpTypes, 188 | Transition, 189 | TransitionGroup, 190 | TriggerOpTypes, 191 | VueElement, 192 | assertNumber, 193 | callWithAsyncErrorHandling, 194 | callWithErrorHandling, 195 | camelize, 196 | capitalize, 197 | cloneVNode, 198 | compatUtils, 199 | compile, 200 | computed, 201 | createApp, 202 | createBlock, 203 | createCommentVNode, 204 | createElementBlock, 205 | createBaseVNode as createElementVNode, 206 | createHydrationRenderer, 207 | createPropsRestProxy, 208 | createRenderer, 209 | createSSRApp, 210 | createSlots, 211 | createStaticVNode, 212 | createTextVNode, 213 | createVNode, 214 | customRef, 215 | defineAsyncComponent, 216 | defineComponent, 217 | defineCustomElement, 218 | defineEmits, 219 | defineExpose, 220 | defineModel, 221 | defineOptions, 222 | defineProps, 223 | defineSSRCustomElement, 224 | defineSlots, 225 | devtools, 226 | effect, 227 | effectScope, 228 | getCurrentInstance, 229 | getCurrentScope, 230 | getCurrentWatcher, 231 | getTransitionRawChildren, 232 | guardReactiveProps, 233 | h, 234 | handleError, 235 | hasInjectionContext, 236 | hydrate, 237 | hydrateOnIdle, 238 | hydrateOnInteraction, 239 | hydrateOnMediaQuery, 240 | hydrateOnVisible, 241 | initCustomFormatter, 242 | initDirectivesForSSR, 243 | inject, 244 | isMemoSame, 245 | isProxy, 246 | isReactive, 247 | isReadonly, 248 | isRef, 249 | isRuntimeOnly, 250 | isShallow, 251 | isVNode, 252 | markRaw, 253 | mergeDefaults, 254 | mergeModels, 255 | mergeProps, 256 | nextTick, 257 | normalizeClass, 258 | normalizeProps, 259 | normalizeStyle, 260 | onActivated, 261 | onBeforeMount, 262 | onBeforeUnmount, 263 | onBeforeUpdate, 264 | onDeactivated, 265 | onErrorCaptured, 266 | onMounted, 267 | onRenderTracked, 268 | onRenderTriggered, 269 | onScopeDispose, 270 | onServerPrefetch, 271 | onUnmounted, 272 | onUpdated, 273 | onWatcherCleanup, 274 | openBlock, 275 | popScopeId, 276 | provide, 277 | proxyRefs, 278 | pushScopeId, 279 | queuePostFlushCb, 280 | reactive, 281 | readonly, 282 | ref, 283 | registerRuntimeCompiler, 284 | render, 285 | renderList, 286 | renderSlot, 287 | resolveComponent, 288 | resolveDirective, 289 | resolveDynamicComponent, 290 | resolveFilter, 291 | resolveTransitionHooks, 292 | setBlockTracking, 293 | setDevtoolsHook, 294 | setTransitionHooks, 295 | shallowReactive, 296 | shallowReadonly, 297 | shallowRef, 298 | ssrContextKey, 299 | ssrUtils, 300 | stop, 301 | toDisplayString, 302 | toHandlerKey, 303 | toHandlers, 304 | toRaw, 305 | toRef, 306 | toRefs, 307 | toValue, 308 | transformVNodeArgs, 309 | triggerRef, 310 | unref, 311 | useAttrs, 312 | useCssModule, 313 | useCssVars, 314 | useHost, 315 | useId, 316 | useModel, 317 | useSSRContext, 318 | useShadowRoot, 319 | useSlots, 320 | useTemplateRef, 321 | useTransitionState, 322 | vModelCheckbox, 323 | vModelDynamic, 324 | vModelRadio, 325 | vModelSelect, 326 | vModelText, 327 | vShow, 328 | version, 329 | warn, 330 | watch, 331 | watchEffect, 332 | watchPostEffect, 333 | watchSyncEffect, 334 | withAsyncContext, 335 | withCtx, 336 | withDefaults, 337 | withDirectives, 338 | withKeys, 339 | withMemo, 340 | withModifiers, 341 | withScopeId 342 | }; 343 | //# sourceMappingURL=vue.js.map 344 | --------------------------------------------------------------------------------