├── .claude
└── settings.local.json
├── .github
└── workflows
│ └── release.yml
├── .gitignore
├── .golangci.yml
├── LICENSE
├── Makefile
├── PRODUCTION_DEPLOYMENT.md
├── README.md
├── api
├── index.html
└── openapi.yaml
├── cmd
├── image.go
├── network.go
├── root.go
├── serve.go
├── snapshot.go
├── storage.go
└── vm.go
├── docs.md
├── flint.example.json
├── flint.service
├── go.mod
├── go.sum
├── install-systemd.sh
├── install.sh
├── main.go
├── pkg
├── activity
│ └── logger.go
├── config
│ └── config.go
├── core
│ ├── types.go
│ └── types_test.go
├── imagerepository
│ └── repository.go
├── libvirtclient
│ ├── client.go
│ ├── cloudinit.go
│ ├── host.go
│ ├── network.go
│ ├── snapshot.go
│ ├── storage.go
│ ├── system_network.go
│ ├── vm.go
│ └── vm_create.go
└── logger
│ └── logger.go
├── roadmap.md
├── server
├── handlers.go
├── handlers_test.go
├── server.go
├── server_test.go
└── testdata
│ └── test.txt
└── web
├── .gitignore
├── app
├── analytics
│ └── page.tsx
├── globals.css
├── images
│ └── page.tsx
├── layout.tsx
├── networking
│ └── page.tsx
├── page.tsx
├── settings
│ └── page.tsx
├── storage
│ └── page.tsx
└── vms
│ ├── console
│ └── page.tsx
│ ├── create
│ └── page.tsx
│ ├── detail
│ └── page.tsx
│ └── page.tsx
├── bun.lock
├── components.json
├── components
├── analytics-view.tsx
├── app-shell.tsx
├── charts
│ ├── gauge-chart.tsx
│ ├── multi-series-chart.tsx
│ └── performance-chart.tsx
├── create-vm-wizard.tsx
├── dashboard-view.tsx
├── enhanced-networking-view.tsx
├── error-boundary.tsx
├── image-repository.tsx
├── images-view.tsx
├── live-console-feed.tsx
├── networking-view.tsx
├── settings-view.tsx
├── shared
│ ├── action-buttons.tsx
│ ├── activity-feed.tsx
│ ├── data-table.tsx
│ ├── image-card.tsx
│ ├── page-layout.tsx
│ ├── quick-actions.tsx
│ ├── resource-card.tsx
│ ├── status-badge.tsx
│ ├── system-alerts.tsx
│ └── vm-card.tsx
├── simple-vm-wizard.tsx
├── storage-view.tsx
├── theme-provider.tsx
├── theme-toggle.tsx
├── ui
│ ├── accordion.tsx
│ ├── alert-dialog.tsx
│ ├── alert.tsx
│ ├── aspect-ratio.tsx
│ ├── avatar.tsx
│ ├── badge.tsx
│ ├── breadcrumb.tsx
│ ├── button.tsx
│ ├── calendar.tsx
│ ├── card.tsx
│ ├── carousel.tsx
│ ├── chart.tsx
│ ├── checkbox.tsx
│ ├── collapsible.tsx
│ ├── command.tsx
│ ├── consistent-button.tsx
│ ├── context-menu.tsx
│ ├── dialog.tsx
│ ├── drawer.tsx
│ ├── dropdown-menu.tsx
│ ├── empty-state.tsx
│ ├── error-state.tsx
│ ├── form.tsx
│ ├── hover-card.tsx
│ ├── input-otp.tsx
│ ├── input.tsx
│ ├── label.tsx
│ ├── loading-state.tsx
│ ├── menubar.tsx
│ ├── navigation-menu.tsx
│ ├── pagination.tsx
│ ├── popover.tsx
│ ├── progress.tsx
│ ├── radio-group.tsx
│ ├── resizable.tsx
│ ├── scroll-area.tsx
│ ├── select.tsx
│ ├── separator.tsx
│ ├── sheet.tsx
│ ├── sidebar.tsx
│ ├── skeleton.tsx
│ ├── slider.tsx
│ ├── sonner.tsx
│ ├── static-safe-wrapper.tsx
│ ├── switch.tsx
│ ├── table.tsx
│ ├── tabs.tsx
│ ├── textarea.tsx
│ ├── toast.tsx
│ ├── toaster.tsx
│ ├── toggle-group.tsx
│ ├── toggle.tsx
│ ├── tooltip.tsx
│ ├── use-mobile.tsx
│ └── use-toast.ts
├── virtual-machine-list-view.tsx
├── vm-detail-view.tsx
├── vm-network-interface-dialog.tsx
├── vm-performance-tab.tsx
├── vm-serial-console.tsx
└── vm-templates.tsx
├── hooks
├── use-api.ts
├── use-mobile.ts
└── use-toast.ts
├── lib
├── api.ts
├── navigation.ts
├── static-safe-hooks.ts
├── ui-constants.ts
└── utils.ts
├── next.config.mjs
├── package.json
├── postcss.config.mjs
├── public
├── android-chrome-192x192.png
├── android-chrome-512x512.png
├── apple-touch-icon.png
├── favicon-16x16.png
├── favicon-32x32.png
├── favicon.ico
├── flint.svg
└── site.webmanifest
├── styles
├── globals.css
└── static-export-fixes.css
├── tailwind.config.js
└── tsconfig.json
/.claude/settings.local.json:
--------------------------------------------------------------------------------
1 | {
2 | "permissions": {
3 | "allow": [
4 | "Bash(gh pr diff:*)"
5 | ],
6 | "deny": [],
7 | "ask": []
8 | }
9 | }
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release Binaries
2 |
3 | on:
4 | push:
5 | tags:
6 | - 'v*'
7 |
8 | jobs:
9 | # --- JOB 1: Build binaries for all target platforms ---
10 | build:
11 | name: Build for ${{ matrix.os }}/${{ matrix.arch }}
12 | strategy:
13 | matrix:
14 | include:
15 | - os: darwin
16 | arch: arm64
17 | runner: ARM64
18 | - os: linux
19 | arch: arm64
20 | runner: ARM64
21 | - os: linux
22 | arch: amd64
23 | runner: AMD64
24 |
25 | runs-on: ${{ matrix.runner }}
26 |
27 | steps:
28 | - name: Checkout Code
29 | uses: actions/checkout@v4
30 |
31 | - name: Setup Go
32 | uses: actions/setup-go@v5
33 | with:
34 | go-version: '1.25.0'
35 |
36 | - name: Install Native Dependencies (Linux AMD64 only)
37 | if: matrix.os == 'linux' && matrix.arch == 'amd64'
38 | run: |
39 | sudo apt-get update
40 | sudo apt-get install -y libvirt-dev pkg-config
41 |
42 | - name: Install Bun
43 | uses: oven-sh/setup-bun@v1
44 | with:
45 | bun-version: latest
46 |
47 | - name: Build Web UI
48 | run: |
49 | cd web
50 | bun install
51 | bun run build
52 | cd ..
53 |
54 | - name: Build Binary
55 | env:
56 | GOOS: ${{ matrix.os }}
57 | GOARCH: ${{ matrix.arch }}
58 | CGO_ENABLED: 1
59 | PKG_CONFIG_PATH: ${{ matrix.os == 'darwin' && '/opt/homebrew/lib/pkgconfig' || '' }}
60 | run: |
61 | if [[ "${{ matrix.os }}" == "linux" && "${{ matrix.arch }}" == "arm64" ]]; then
62 | echo "--- Building linux/arm64 via Docker ---"
63 | docker run --rm --platform linux/arm64 \
64 | -v "$PWD":/src \
65 | -w /src \
66 | debian:bullseye \
67 | bash -c ' \
68 | set -e && \
69 | export DEBIAN_FRONTEND=noninteractive && \
70 | apt-get update && \
71 | apt-get install -y build-essential pkg-config libvirt-dev wget && \
72 | export GO_VERSION="1.25.0" && \
73 | wget "https://golang.org/dl/go${GO_VERSION}.linux-arm64.tar.gz" && \
74 | tar -C /usr/local -xzf "go${GO_VERSION}.linux-arm64.tar.gz" && \
75 | export PATH="/usr/local/go/bin:${PATH}" && \
76 | echo "--- Compiling Go binary inside container ---" && \
77 | go mod download && \
78 | go build -ldflags="-s -w" -o flint . \
79 | '
80 | else
81 | echo "--- Native build for ${{ matrix.os }}/${{ matrix.arch }} ---"
82 | go build -ldflags="-s -w" -o flint .
83 | fi
84 |
85 | - name: Package Binary
86 | run: |
87 | zip -j flint-${{ matrix.os }}-${{ matrix.arch }}.zip flint
88 |
89 | - name: Upload Artifact
90 | uses: actions/upload-artifact@v4
91 | with:
92 | name: flint-binary-${{ matrix.os }}-${{ matrix.arch }}
93 | path: flint-${{ matrix.os }}-${{ matrix.arch }}.zip
94 |
95 | # --- JOB 2: Create the GitHub Release after all builds are done ---
96 | release:
97 | name: Create GitHub Release
98 | needs: build
99 | runs-on: ubuntu-latest
100 | permissions:
101 | contents: write
102 |
103 | steps:
104 | - name: Download all build artifacts
105 | uses: actions/download-artifact@v4
106 | # No 'path' property here. This downloads all artifacts from the run
107 | # into folders named after the artifacts in the current directory.
108 |
109 | - name: Display structure of downloaded files (for debugging)
110 | run: ls -R
111 |
112 | - name: Create Release and Upload Assets
113 | uses: softprops/action-gh-release@v2
114 | with:
115 | # The '**' glob recursively finds all .zip files to upload to the release.
116 | files: "**/*.zip"
117 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # General Node.js / Next.js
2 | node_modules/
3 | .next/
4 | .env
5 | .env.local
6 | .env.production
7 | .env.development
8 | .env.test
9 | .npmrc
10 | *.log
11 | .swp
12 |
13 | # Next.js SWC build cache
14 | .swc/
15 |
16 | # Go
17 | # Binaries for programs and plugins
18 | *.exe
19 | *.exe~
20 | *.dll
21 | *.so
22 | *.dylib
23 | *.test
24 | *.out
25 |
26 | # Output of go build
27 | /build/
28 | dist/
29 | bin/
30 |
31 | # Go workspace file
32 | go.work
33 |
34 | # Go module cache
35 | vendor/
36 |
37 | # Go test cache
38 | *.coverprofile
39 |
40 | # IDE/editor files
41 | .vscode/
42 | .idea/
43 | .DS_Store
44 | Thumbs.db
45 |
46 | flint
--------------------------------------------------------------------------------
/.golangci.yml:
--------------------------------------------------------------------------------
1 | run:
2 | timeout: 5m
3 | issues-exit-code: 1
4 | tests: true
5 | build-tags: []
6 | skip-dirs:
7 | - vendor
8 | - testdata
9 | skip-files:
10 | - ".*\\.pb\\.go$"
11 | - ".*_test\\.go$"
12 |
13 | output:
14 | format: colored-line-number
15 | print-issued-lines: true
16 | print-linter-name: true
17 |
18 | linters-settings:
19 | govet:
20 | check-shadowing: true
21 | golint:
22 | min-confidence: 0
23 | gocyclo:
24 | min-complexity: 15
25 | maligned:
26 | suggest-new: true
27 | dupl:
28 | threshold: 100
29 | goconst:
30 | min-len: 2
31 | min-occurrences: 2
32 | misspell:
33 | locale: US
34 | lll:
35 | line-length: 120
36 | goimports:
37 | local-prefixes: github.com/ccheshirecat/flint
38 | gocritic:
39 | enabled-tags:
40 | - diagnostic
41 | - experimental
42 | - opinionated
43 | - performance
44 | - style
45 | disabled-checks:
46 | - dupImport # https://github.com/go-critic/go-critic/issues/845
47 | - ifElseChain
48 | - octalLiteral
49 | - whyNoLint
50 | - wrapperFunc
51 |
52 | linters:
53 | disable-all: true
54 | enable:
55 | - bodyclose
56 | - deadcode
57 | - depguard
58 | - dogsled
59 | - dupl
60 | - errcheck
61 | - exportloopref
62 | - exhaustive
63 | - gochecknoinits
64 | - goconst
65 | - gocritic
66 | - gocyclo
67 | - gofmt
68 | - goimports
69 | - golint
70 | - gomnd
71 | - goprintffuncname
72 | - gosec
73 | - gosimple
74 | - govet
75 | - ineffassign
76 | - interfacer
77 | - lll
78 | - misspell
79 | - nakedret
80 | - noctx
81 | - nolintlint
82 | - rowserrcheck
83 | - scopelint
84 | - staticcheck
85 | - structcheck
86 | - stylecheck
87 | - typecheck
88 | - unconvert
89 | - unparam
90 | - unused
91 | - varcheck
92 | - whitespace
93 |
94 | issues:
95 | exclude-rules:
96 | - path: _test\.go
97 | linters:
98 | - gomnd
99 | - goconst
100 | - lll
101 | - path: server/handlers\.go
102 | linters:
103 | - lll # Allow longer lines in handlers
104 | - path: pkg/libvirtclient/
105 | linters:
106 | - gomnd # Allow magic numbers in libvirt client
107 | exclude-use-default: false
108 | exclude:
109 | # Exclude some linters from running on tests files
110 | - "G404" # Use of weak random number generator in tests
111 | - "G501" # Import blacklist: crypto/md5
--------------------------------------------------------------------------------
/cmd/root.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "embed"
5 |
6 | "github.com/spf13/cobra"
7 | )
8 |
9 | var socketPath string
10 | var globalAssets embed.FS
11 |
12 | var rootCmd = &cobra.Command{
13 | Use: "flint",
14 | Short: "flint is a modern, self-contained KVM management tool.",
15 | }
16 |
17 | func Execute() {
18 | cobra.CheckErr(rootCmd.Execute())
19 | }
20 |
21 | func ExecuteWithAssets(assets embed.FS) {
22 | globalAssets = assets
23 | cobra.CheckErr(rootCmd.Execute())
24 | }
25 |
26 | func init() {
27 | // This flag will be available to all subcommands
28 | rootCmd.PersistentFlags().StringVar(&socketPath, "socket", "/var/run/libvirt/libvirt-sock", "Path to the libvirt socket")
29 | rootCmd.AddCommand(serveCmd)
30 | rootCmd.AddCommand(vmCmd)
31 | rootCmd.AddCommand(snapshotCmd)
32 | rootCmd.AddCommand(networkCmd)
33 | rootCmd.AddCommand(storageCmd)
34 | rootCmd.AddCommand(imageCmd)
35 | rootCmd.AddCommand(apiKeyCmd)
36 | }
37 |
--------------------------------------------------------------------------------
/flint.example.json:
--------------------------------------------------------------------------------
1 | {
2 | "server": {
3 | "host": "0.0.0.0",
4 | "port": 5550,
5 | "read_timeout": 30,
6 | "write_timeout": 30
7 | },
8 | "security": {
9 | "rate_limit_requests": 100,
10 | "rate_limit_burst": 20
11 | },
12 | "libvirt": {
13 | "uri": "qemu:///system",
14 | "iso_pool": "isos",
15 | "template_pool": "templates",
16 | "image_pool_path": "/var/lib/flint/images"
17 | },
18 | "logging": {
19 | "level": "INFO",
20 | "format": "json"
21 | }
22 | }
--------------------------------------------------------------------------------
/flint.service:
--------------------------------------------------------------------------------
1 | [Unit]
2 | Description=Flint KVM Management Tool
3 | After=network.target libvirtd.service
4 | Requires=libvirtd.service
5 |
6 | [Service]
7 | Type=simple
8 | User=flint
9 | Group=flint
10 | ExecStart=/usr/local/bin/flint serve
11 | ExecReload=/bin/kill -HUP $MAINPID
12 | Restart=always
13 | RestartSec=5
14 | Environment=HOME=/var/lib/flint
15 |
16 | # Security settings
17 | NoNewPrivileges=yes
18 | PrivateTmp=yes
19 | ProtectSystem=strict
20 | ProtectHome=yes
21 | ReadWritePaths=/var/lib/flint /var/lib/libvirt /var/run/libvirt
22 | ProtectKernelTunables=yes
23 | ProtectKernelModules=yes
24 | ProtectControlGroups=yes
25 |
26 | # Resource limits
27 | LimitNOFILE=65536
28 | MemoryLimit=1G
29 |
30 | [Install]
31 | WantedBy=multi-user.target
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/ccheshirecat/flint
2 |
3 | go 1.25.0
4 |
5 | require (
6 | github.com/go-chi/chi/v5 v5.2.3
7 | github.com/gorilla/websocket v1.5.3
8 | github.com/libvirt/libvirt-go v7.4.0+incompatible
9 | github.com/spf13/cobra v1.10.1
10 | golang.org/x/crypto v0.41.0
11 | golang.org/x/term v0.35.0
12 | )
13 |
14 | require (
15 | github.com/inconshreveable/mousetrap v1.1.0 // indirect
16 | github.com/spf13/pflag v1.0.10 // indirect
17 | golang.org/x/sys v0.36.0 // indirect
18 | )
19 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
2 | github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
3 | github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
4 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
5 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
6 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
7 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
8 | github.com/libvirt/libvirt-go v7.4.0+incompatible h1:crnSLkwPqCdXtg6jib/FxBG/hweAc/3Wxth1AehCXL4=
9 | github.com/libvirt/libvirt-go v7.4.0+incompatible/go.mod h1:34zsnB4iGeOv7Byj6qotuW8Ya4v4Tr43ttjz/F0wjLE=
10 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
11 | github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
12 | github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
13 | github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
14 | github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
15 | github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
16 | golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
17 | golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
18 | golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
19 | golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
20 | golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
21 | golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
22 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
23 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
24 |
--------------------------------------------------------------------------------
/install-systemd.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -euo pipefail
3 |
4 | # Flint Systemd Installation Script
5 | # This script installs Flint as a systemd service
6 |
7 | GREEN='\033[0;32m'
8 | RED='\033[0;31m'
9 | YELLOW='\033[1;33m'
10 | NC='\033[0m' # No Color
11 |
12 | log_info() {
13 | echo -e "${GREEN}[INFO]${NC} $1"
14 | }
15 |
16 | log_warn() {
17 | echo -e "${YELLOW}[WARN]${NC} $1"
18 | }
19 |
20 | log_error() {
21 | echo -e "${RED}[ERROR]${NC} $1"
22 | }
23 |
24 | # Check if running as root
25 | if [[ $EUID -ne 0 ]]; then
26 | log_error "This script must be run as root"
27 | exit 1
28 | fi
29 |
30 | # Check if Flint is installed
31 | if ! command -v flint &> /dev/null; then
32 | log_error "Flint is not installed. Please install it first:"
33 | echo "curl -fsSL https://raw.githubusercontent.com/ccheshirecat/flint/main/install.sh | sh"
34 | exit 1
35 | fi
36 |
37 | # Check if libvirtd is running
38 | if ! systemctl is-active --quiet libvirtd; then
39 | log_error "libvirtd is not running. Please start it first:"
40 | echo "systemctl start libvirtd"
41 | exit 1
42 | fi
43 |
44 | log_info "Installing Flint as a systemd service..."
45 |
46 | # Create flint user and group
47 | if ! id -u flint &>/dev/null; then
48 | log_info "Creating flint user..."
49 | useradd -r -s /bin/false -d /var/lib/flint -m flint
50 | fi
51 |
52 | # Create necessary directories
53 | log_info "Creating directories..."
54 | mkdir -p /var/lib/flint
55 | mkdir -p /var/lib/flint/images
56 | mkdir -p /var/log/flint
57 |
58 | # Set ownership
59 | chown -R flint:flint /var/lib/flint
60 | chown -R flint:flint /var/log/flint
61 |
62 | # Add flint user to libvirt group for access to libvirt socket
63 | usermod -a -G libvirt flint
64 |
65 | # Copy systemd service file
66 | log_info "Installing systemd service..."
67 | cp flint.service /etc/systemd/system/
68 | chmod 644 /etc/systemd/system/flint.service
69 |
70 | # Reload systemd
71 | systemctl daemon-reload
72 |
73 | # Enable and start service
74 | log_info "Enabling and starting Flint service..."
75 | systemctl enable flint
76 | systemctl start flint
77 |
78 | # Check status
79 | if systemctl is-active --quiet flint; then
80 | log_info "Flint service started successfully!"
81 | log_info "Service status: $(systemctl is-active flint)"
82 | log_info "Flint is now running at: http://localhost:5550"
83 | log_info "API Key: $(su - flint -c 'flint api-key' 2>/dev/null | grep 'Flint API Key' | cut -d: -f2 | xargs)"
84 | else
85 | log_error "Failed to start Flint service"
86 | log_info "Check service status: systemctl status flint"
87 | log_info "Check logs: journalctl -u flint -f"
88 | exit 1
89 | fi
90 |
91 | log_info "Installation complete!"
92 | log_info ""
93 | log_info "Useful commands:"
94 | log_info " Start service: systemctl start flint"
95 | log_info " Stop service: systemctl stop flint"
96 | log_info " Restart service: systemctl restart flint"
97 | log_info " View logs: journalctl -u flint -f"
98 | log_info " Check status: systemctl status flint"
--------------------------------------------------------------------------------
/install.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | set -euo pipefail
3 |
4 | REPO="ccheshirecat/flint"
5 | INSTALL_DIR="/usr/local/bin"
6 | BINARY_NAME="flint"
7 |
8 | # Colors
9 | green() { printf "\033[32m%s\033[0m\n" "$1"; }
10 | red() { printf "\033[31m%s\033[0m\n" "$1"; }
11 | yellow(){ printf "\033[33m%s\033[0m\n" "$1"; }
12 |
13 | # Detect latest version from GitHub API
14 | get_latest_release() {
15 | curl -s "https://api.github.com/repos/${REPO}/releases/latest" \
16 | | grep '"tag_name":' \
17 | | sed -E 's/.*"([^"]+)".*/\1/'
18 | }
19 |
20 | # Detect OS and Arch
21 | detect_platform() {
22 | local os="$(uname | tr '[:upper:]' '[:lower:]')"
23 | local arch="$(uname -m)"
24 |
25 | case "$arch" in
26 | x86_64|amd64) arch="amd64" ;;
27 | arm64|aarch64) arch="arm64" ;;
28 | *) red "❌ Unsupported architecture: $arch" && exit 1 ;;
29 | esac
30 |
31 | case "$os" in
32 | linux) platform="linux" ;;
33 | darwin) platform="darwin" ;;
34 | *) red "❌ Unsupported OS: $os" && exit 1 ;;
35 | esac
36 |
37 | echo "${platform}-${arch}"
38 | }
39 |
40 | main() {
41 | green "🚀 Installing Flint..."
42 |
43 | latest_version=$(get_latest_release)
44 | if [[ -z "$latest_version" ]]; then
45 | red "❌ Failed to fetch latest release."
46 | exit 1
47 | fi
48 | yellow "ℹ️ Latest version: $latest_version"
49 |
50 | platform=$(detect_platform)
51 | green "✅ Detected platform: $platform"
52 |
53 | url="https://github.com/${REPO}/releases/download/${latest_version}/${BINARY_NAME}-${platform}.zip"
54 | tmp_dir=$(mktemp -d)
55 | trap 'rm -rf "$tmp_dir"' EXIT
56 |
57 | yellow "⬇️ Downloading from $url"
58 | curl -L --progress-bar "$url" -o "$tmp_dir/${BINARY_NAME}.zip"
59 |
60 | yellow "📦 Extracting..."
61 | unzip -q "$tmp_dir/${BINARY_NAME}.zip" -d "$tmp_dir"
62 |
63 | sudo mkdir -p "$INSTALL_DIR"
64 | sudo mv "$tmp_dir/$BINARY_NAME" "$INSTALL_DIR/$BINARY_NAME"
65 | sudo chmod +x "$INSTALL_DIR/$BINARY_NAME"
66 |
67 | green "✅ Installed $BINARY_NAME to $INSTALL_DIR/$BINARY_NAME"
68 |
69 | # Create config directory with proper permissions
70 | config_dir="$HOME/.flint"
71 | if [[ ! -d "$config_dir" ]]; then
72 | mkdir -p "$config_dir"
73 | chmod 755 "$config_dir"
74 | green "📁 Created config directory: $config_dir"
75 | fi
76 |
77 | echo
78 | green "🎉 Flint installation complete!"
79 | echo "Run: flint serve"
80 | echo
81 | yellow "ℹ️ Your API key will be automatically generated and saved to:"
82 | echo " $config_dir/config.json"
83 | echo " You can view/modify it there if needed."
84 | }
85 |
86 | main "$@"
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "embed"
5 | "github.com/ccheshirecat/flint/cmd"
6 | )
7 |
8 | //go:embed web/out/*
9 | //go:embed web/public/*
10 | var assets embed.FS
11 |
12 | func main() {
13 | cmd.ExecuteWithAssets(assets)
14 | }
15 |
--------------------------------------------------------------------------------
/pkg/activity/logger.go:
--------------------------------------------------------------------------------
1 | package activity
2 |
3 | import (
4 | "crypto/rand"
5 | "encoding/hex"
6 | "github.com/ccheshirecat/flint/pkg/core"
7 | "sync"
8 | "time"
9 | )
10 |
11 | // Logger is an in-memory, thread-safe, capped-size activity logger.
12 | type Logger struct {
13 | mu sync.RWMutex
14 | events []core.ActivityEvent
15 | maxSize int
16 | }
17 |
18 | // NewLogger creates a new activity logger with the specified max size.
19 | func NewLogger(maxSize int) *Logger {
20 | if maxSize <= 0 {
21 | maxSize = 50 // default
22 | }
23 | return &Logger{
24 | events: make([]core.ActivityEvent, 0, maxSize),
25 | maxSize: maxSize,
26 | }
27 | }
28 |
29 | // Add adds a new activity event.
30 | func (l *Logger) Add(action, target, status, message string) {
31 | l.mu.Lock()
32 | defer l.mu.Unlock()
33 |
34 | // Generate a simple ID
35 | idBytes := make([]byte, 8)
36 | rand.Read(idBytes)
37 | id := hex.EncodeToString(idBytes)
38 |
39 | event := core.ActivityEvent{
40 | ID: id,
41 | Timestamp: time.Now().Unix(),
42 | Action: action,
43 | Target: target,
44 | Status: status,
45 | Message: message,
46 | }
47 |
48 | l.events = append(l.events, event)
49 |
50 | // Trim if we exceed max size
51 | if len(l.events) > l.maxSize {
52 | // Keep the most recent events
53 | l.events = l.events[len(l.events)-l.maxSize:]
54 | }
55 | }
56 |
57 | // Get returns a copy of all current activity events.
58 | func (l *Logger) Get() []core.ActivityEvent {
59 | l.mu.RLock()
60 | defer l.mu.RUnlock()
61 |
62 | // Return a copy to prevent external modification
63 | result := make([]core.ActivityEvent, len(l.events))
64 | copy(result, l.events)
65 | return result
66 | }
67 |
--------------------------------------------------------------------------------
/pkg/core/types_test.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "testing"
5 | "time"
6 | )
7 |
8 | func TestVM_Summary(t *testing.T) {
9 | vm := VM_Summary{
10 | Name: "test-vm",
11 | UUID: "550e8400-e29b-41d4-a716-446655440000",
12 | State: "running",
13 | MemoryKB: 2097152, // 2GB
14 | VCPUs: 2,
15 | CPUPercent: 25.5,
16 | UptimeSec: 3600,
17 | OSInfo: "Ubuntu 24.04",
18 | IPAddresses: []string{"192.168.122.100"},
19 | }
20 |
21 | if vm.Name != "test-vm" {
22 | t.Errorf("Expected name 'test-vm', got '%s'", vm.Name)
23 | }
24 |
25 | if vm.VCPUs != 2 {
26 | t.Errorf("Expected 2 VCPUs, got %d", vm.VCPUs)
27 | }
28 |
29 | if len(vm.IPAddresses) != 1 {
30 | t.Errorf("Expected 1 IP address, got %d", len(vm.IPAddresses))
31 | }
32 | }
33 |
34 | func TestActivityEvent(t *testing.T) {
35 | now := time.Now().Unix()
36 | event := ActivityEvent{
37 | ID: "event-1",
38 | Timestamp: now,
39 | Action: "VM Started",
40 | Target: "web-server-01",
41 | Status: "Success",
42 | Message: "VM started successfully",
43 | }
44 |
45 | if event.Action != "VM Started" {
46 | t.Errorf("Expected action 'VM Started', got '%s'", event.Action)
47 | }
48 |
49 | if event.Status != "Success" {
50 | t.Errorf("Expected status 'Success', got '%s'", event.Status)
51 | }
52 |
53 | if event.Timestamp != now {
54 | t.Errorf("Expected timestamp %d, got %d", now, event.Timestamp)
55 | }
56 | }
57 |
58 | func TestHostStatus(t *testing.T) {
59 | status := HostStatus{
60 | Hostname: "test-host",
61 | HypervisorVersion: "8.0.0",
62 | TotalVMs: 5,
63 | RunningVMs: 3,
64 | PausedVMs: 1,
65 | ShutOffVMs: 1,
66 | HealthChecks: []HealthCheck{
67 | {Type: "warning", Message: "High CPU usage"},
68 | },
69 | }
70 |
71 | if status.Hostname != "test-host" {
72 | t.Errorf("Expected hostname 'test-host', got '%s'", status.Hostname)
73 | }
74 |
75 | if status.TotalVMs != 5 {
76 | t.Errorf("Expected 5 total VMs, got %d", status.TotalVMs)
77 | }
78 |
79 | if len(status.HealthChecks) != 1 {
80 | t.Errorf("Expected 1 health check, got %d", len(status.HealthChecks))
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/pkg/libvirtclient/cloudinit.go:
--------------------------------------------------------------------------------
1 | package libvirtclient
2 |
3 | import (
4 | "fmt"
5 | "github.com/ccheshirecat/flint/pkg/core"
6 | "strings"
7 | )
8 |
9 | // generateUserDataYAML generates cloud-init user data YAML from config
10 | func generateUserDataYAML(cfg *core.CloudInitConfig) (string, error) {
11 | if cfg == nil {
12 | return "", nil
13 | }
14 |
15 | // If raw user data is provided, use it directly
16 | if cfg.RawUserData != "" {
17 | return cfg.RawUserData, nil
18 | }
19 |
20 | // Generate YAML from common fields
21 | var yaml strings.Builder
22 | yaml.WriteString("#cloud-config\n")
23 |
24 | if cfg.CommonFields.Hostname != "" {
25 | yaml.WriteString(fmt.Sprintf("hostname: %s\n", cfg.CommonFields.Hostname))
26 | }
27 |
28 | // Create default user if username is provided
29 | if cfg.CommonFields.Username != "" {
30 | yaml.WriteString("users:\n")
31 | yaml.WriteString(fmt.Sprintf(" - name: %s\n", cfg.CommonFields.Username))
32 | yaml.WriteString(" sudo: ALL=(ALL) NOPASSWD:ALL\n")
33 | yaml.WriteString(" groups: users, admin\n")
34 | yaml.WriteString(" shell: /bin/bash\n")
35 | yaml.WriteString(" lock_passwd: false\n")
36 | yaml.WriteString(" lock_passwd: false\n")
37 | }
38 |
39 | // Set password using chpasswd (works with plain text)
40 | if cfg.CommonFields.Password != "" && cfg.CommonFields.Username != "" {
41 | yaml.WriteString("chpasswd:\n")
42 | yaml.WriteString(" list: |\n")
43 | yaml.WriteString(fmt.Sprintf(" %s:%s\n", cfg.CommonFields.Username, cfg.CommonFields.Password))
44 | yaml.WriteString(" expire: false\n")
45 | } else if cfg.CommonFields.Password != "" {
46 | // Set password for default user if no custom username
47 | yaml.WriteString(fmt.Sprintf("password: %s\n", cfg.CommonFields.Password))
48 | yaml.WriteString("chpasswd:\n")
49 | yaml.WriteString(" expire: false\n")
50 | yaml.WriteString("ssh_pwauth: true\n")
51 | }
52 |
53 | if cfg.CommonFields.SSHKeys != "" {
54 | yaml.WriteString("ssh_authorized_keys:\n")
55 | // Split keys by newline and add each one
56 | keys := strings.Split(strings.TrimSpace(cfg.CommonFields.SSHKeys), "\n")
57 | for _, key := range keys {
58 | key = strings.TrimSpace(key)
59 | if key != "" {
60 | yaml.WriteString(fmt.Sprintf(" - %s\n", key))
61 | }
62 | }
63 | }
64 |
65 | // Add network configuration
66 | if cfg.CommonFields.NetworkConfig != nil {
67 | yaml.WriteString("network:\n")
68 | yaml.WriteString(" version: 2\n")
69 | yaml.WriteString(" ethernets:\n")
70 | yaml.WriteString(" ens3:\n") // Default interface name for cloud-init
71 | if cfg.CommonFields.NetworkConfig.UseDHCP {
72 | yaml.WriteString(" dhcp4: true\n")
73 | } else {
74 | yaml.WriteString(" dhcp4: false\n")
75 | if cfg.CommonFields.NetworkConfig.IPAddress != "" {
76 | yaml.WriteString(fmt.Sprintf(" addresses: [%s/%d]\n", cfg.CommonFields.NetworkConfig.IPAddress, cfg.CommonFields.NetworkConfig.Prefix))
77 | }
78 | if cfg.CommonFields.NetworkConfig.Gateway != "" {
79 | yaml.WriteString(fmt.Sprintf(" gateway4: %s\n", cfg.CommonFields.NetworkConfig.Gateway))
80 | }
81 | if len(cfg.CommonFields.NetworkConfig.DNSServers) > 0 {
82 | yaml.WriteString(" nameservers:\n")
83 | yaml.WriteString(" addresses:\n")
84 | for _, dns := range cfg.CommonFields.NetworkConfig.DNSServers {
85 | yaml.WriteString(fmt.Sprintf(" - %s\n", dns))
86 | }
87 | }
88 | }
89 | }
90 |
91 | return yaml.String(), nil
92 | }
93 |
--------------------------------------------------------------------------------
/pkg/libvirtclient/snapshot.go:
--------------------------------------------------------------------------------
1 | package libvirtclient
2 |
3 | import (
4 | "encoding/xml"
5 | "fmt"
6 | "github.com/ccheshirecat/flint/pkg/core"
7 | libvirt "github.com/libvirt/libvirt-go"
8 | )
9 |
10 | // GetVMSnapshots lists all snapshots for a given VM.
11 | func (c *Client) GetVMSnapshots(uuidStr string) ([]core.Snapshot, error) {
12 | dom, err := c.conn.LookupDomainByUUIDString(uuidStr)
13 | if err != nil {
14 | return nil, fmt.Errorf("lookup domain: %w", err)
15 | }
16 | defer dom.Free()
17 |
18 | // Get snapshot names first
19 | snapNames, err := dom.SnapshotListNames(0)
20 | if err != nil {
21 | return nil, fmt.Errorf("list snapshot names: %w", err)
22 | }
23 |
24 | out := make([]core.Snapshot, 0, len(snapNames))
25 | for _, snapName := range snapNames {
26 | snap, err := dom.SnapshotLookupByName(snapName, 0)
27 | if err != nil {
28 | continue // Skip snapshots we can't access
29 | }
30 |
31 | xmlDesc, err := snap.GetXMLDesc(0)
32 | if err != nil {
33 | snap.Free()
34 | continue // Skip snapshots we can't read
35 | }
36 |
37 | // Unmarshal the snapshot XML to get details
38 | type snapshotXML struct {
39 | Name string `xml:"name"`
40 | Description string `xml:"description"`
41 | State string `xml:"state"`
42 | CreationTime int64 `xml:"creationTime"`
43 | }
44 | var sx snapshotXML
45 | if xml.Unmarshal([]byte(xmlDesc), &sx) == nil {
46 | out = append(out, core.Snapshot{
47 | Name: sx.Name,
48 | State: sx.State,
49 | CreationTS: sx.CreationTime,
50 | Description: sx.Description,
51 | })
52 | }
53 | snap.Free()
54 | }
55 | return out, nil
56 | }
57 |
58 | // CreateVMSnapshot creates a new snapshot from a name and description.
59 | func (c *Client) CreateVMSnapshot(uuidStr string, cfg core.CreateSnapshotRequest) (core.Snapshot, error) {
60 | dom, err := c.conn.LookupDomainByUUIDString(uuidStr)
61 | if err != nil {
62 | return core.Snapshot{}, fmt.Errorf("lookup domain: %w", err)
63 | }
64 | defer dom.Free()
65 |
66 | // Generate snapshot XML
67 | xmlDesc := fmt.Sprintf(` Manage virtual machine images and cloud repositoryImages
37 |
Please select a VM to access its console
28 | 32 |73 | {error.message} 74 |
75 |Hardware and system details
68 |{activity.action}
29 |{activity.target}
30 |{description}
} 22 |69 | {trend === "up" ? "↗" : trend === "down" ? "↘" : "→"} 70 | {trendValue} 71 |
72 | )} 73 | {children} 74 |33 | {description} 34 |
35 | )} 36 |45 | {description} 46 |
47 | )} 48 | {showRefresh && ( 49 |160 | {body} 161 |
162 | ) 163 | } 164 | 165 | export { 166 | useFormField, 167 | Form, 168 | FormItem, 169 | FormLabel, 170 | FormControl, 171 | FormDescription, 172 | FormMessage, 173 | FormField, 174 | } -------------------------------------------------------------------------------- /web/components/ui/hover-card.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as HoverCardPrimitive from "@radix-ui/react-hover-card" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function HoverCard({ 9 | ...props 10 | }: React.ComponentProps43 | {description} 44 |
45 | )} 46 |