├── .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(` 68 | %s 69 | %s 70 | `, cfg.Name, cfg.Description) 71 | 72 | snap, err := dom.CreateSnapshotXML(xmlDesc, 0) 73 | if err != nil { 74 | return core.Snapshot{}, fmt.Errorf("create snapshot: %w", err) 75 | } 76 | defer snap.Free() 77 | 78 | // Get snapshot details 79 | return c.getSnapshotDetails(snap) 80 | } 81 | 82 | // DeleteVMSnapshot deletes a snapshot by its name. 83 | func (c *Client) DeleteVMSnapshot(uuidStr string, snapshotName string) error { 84 | dom, err := c.conn.LookupDomainByUUIDString(uuidStr) 85 | if err != nil { 86 | return fmt.Errorf("lookup domain: %w", err) 87 | } 88 | defer dom.Free() 89 | 90 | snap, err := dom.SnapshotLookupByName(snapshotName, 0) 91 | if err != nil { 92 | return fmt.Errorf("snapshot '%s' not found: %w", snapshotName, err) 93 | } 94 | defer snap.Free() 95 | 96 | // The '0' flag means default behavior. 97 | return snap.Delete(0) 98 | } 99 | 100 | // RevertToVMSnapshot reverts a VM's state. 101 | func (c *Client) RevertToVMSnapshot(uuidStr string, snapshotName string) error { 102 | dom, err := c.conn.LookupDomainByUUIDString(uuidStr) 103 | if err != nil { 104 | return fmt.Errorf("lookup domain: %w", err) 105 | } 106 | defer dom.Free() 107 | 108 | snap, err := dom.SnapshotLookupByName(snapshotName, 0) 109 | if err != nil { 110 | return fmt.Errorf("snapshot '%s' not found: %w", snapshotName, err) 111 | } 112 | defer snap.Free() 113 | 114 | // Revert to snapshot 115 | err = snap.RevertToSnapshot(0) 116 | if err != nil { 117 | return fmt.Errorf("revert to snapshot: %w", err) 118 | } 119 | 120 | return nil 121 | } 122 | 123 | // getSnapshotDetails is a helper to extract snapshot details from a libvirt snapshot object. 124 | func (c *Client) getSnapshotDetails(snap *libvirt.DomainSnapshot) (core.Snapshot, error) { 125 | xmlDesc, err := snap.GetXMLDesc(0) 126 | if err != nil { 127 | return core.Snapshot{}, fmt.Errorf("get snapshot XML: %w", err) 128 | } 129 | 130 | // Unmarshal the snapshot XML to get details 131 | type snapshotXML struct { 132 | Name string `xml:"name"` 133 | Description string `xml:"description"` 134 | State string `xml:"state"` 135 | CreationTime int64 `xml:"creationTime"` 136 | } 137 | var sx snapshotXML 138 | if err := xml.Unmarshal([]byte(xmlDesc), &sx); err != nil { 139 | return core.Snapshot{}, fmt.Errorf("unmarshal snapshot XML: %w", err) 140 | } 141 | 142 | return core.Snapshot{ 143 | Name: sx.Name, 144 | State: sx.State, 145 | CreationTS: sx.CreationTime, 146 | Description: sx.Description, 147 | }, nil 148 | } 149 | -------------------------------------------------------------------------------- /pkg/libvirtclient/system_network.go: -------------------------------------------------------------------------------- 1 | package libvirtclient 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "net" 7 | "os" 8 | "strconv" 9 | "strings" 10 | "github.com/ccheshirecat/flint/pkg/core" 11 | ) 12 | 13 | // GetSystemInterfaces returns all system network interfaces 14 | func (c *Client) GetSystemInterfaces() ([]core.SystemInterface, error) { 15 | interfaces, err := net.Interfaces() 16 | if err != nil { 17 | return nil, fmt.Errorf("failed to get network interfaces: %w", err) 18 | } 19 | 20 | var systemInterfaces []core.SystemInterface 21 | 22 | for _, iface := range interfaces { 23 | // Skip loopback interface 24 | if iface.Flags&net.FlagLoopback != 0 { 25 | continue 26 | } 27 | 28 | sysIface := core.SystemInterface{ 29 | Name: iface.Name, 30 | MACAddress: iface.HardwareAddr.String(), 31 | MTU: iface.MTU, 32 | } 33 | 34 | // Determine interface type 35 | sysIface.Type = determineInterfaceType(iface.Name) 36 | 37 | // Get interface state 38 | if iface.Flags&net.FlagUp != 0 { 39 | sysIface.State = "up" 40 | } else { 41 | sysIface.State = "down" 42 | } 43 | 44 | // Get IP addresses 45 | addrs, err := iface.Addrs() 46 | if err == nil { 47 | for _, addr := range addrs { 48 | if ipnet, ok := addr.(*net.IPNet); ok { 49 | sysIface.IPAddresses = append(sysIface.IPAddresses, ipnet.String()) 50 | } 51 | } 52 | } 53 | 54 | // Get network statistics 55 | stats, err := getInterfaceStats(iface.Name) 56 | if err == nil { 57 | sysIface.RxBytes = stats.RxBytes 58 | sysIface.TxBytes = stats.TxBytes 59 | sysIface.RxPackets = stats.RxPackets 60 | sysIface.TxPackets = stats.TxPackets 61 | } 62 | 63 | // Get interface speed 64 | speed, err := getInterfaceSpeed(iface.Name) 65 | if err == nil { 66 | sysIface.Speed = speed 67 | } 68 | 69 | systemInterfaces = append(systemInterfaces, sysIface) 70 | } 71 | 72 | return systemInterfaces, nil 73 | } 74 | 75 | // determineInterfaceType determines the type of network interface 76 | func determineInterfaceType(name string) string { 77 | switch { 78 | case strings.HasPrefix(name, "br"): 79 | return "bridge" 80 | case strings.HasPrefix(name, "tap"): 81 | return "tap" 82 | case strings.HasPrefix(name, "vnet"): 83 | return "virtual" 84 | case strings.HasPrefix(name, "virbr"): 85 | return "libvirt-bridge" 86 | case strings.HasPrefix(name, "en"): 87 | return "physical" 88 | case strings.HasPrefix(name, "eth"): 89 | return "physical" 90 | case strings.HasPrefix(name, "wl"): 91 | return "wireless" 92 | default: 93 | return "unknown" 94 | } 95 | } 96 | 97 | // InterfaceStats represents network interface statistics 98 | type InterfaceStats struct { 99 | RxBytes uint64 100 | TxBytes uint64 101 | RxPackets uint64 102 | TxPackets uint64 103 | } 104 | 105 | // getInterfaceStats reads network statistics from /proc/net/dev 106 | func getInterfaceStats(interfaceName string) (*InterfaceStats, error) { 107 | file, err := os.Open("/proc/net/dev") 108 | if err != nil { 109 | return nil, err 110 | } 111 | defer file.Close() 112 | 113 | scanner := bufio.NewScanner(file) 114 | for scanner.Scan() { 115 | line := scanner.Text() 116 | if strings.Contains(line, interfaceName+":") { 117 | fields := strings.Fields(line) 118 | if len(fields) >= 10 { 119 | rxBytes, _ := strconv.ParseUint(fields[1], 10, 64) 120 | rxPackets, _ := strconv.ParseUint(fields[2], 10, 64) 121 | txBytes, _ := strconv.ParseUint(fields[9], 10, 64) 122 | txPackets, _ := strconv.ParseUint(fields[10], 10, 64) 123 | 124 | return &InterfaceStats{ 125 | RxBytes: rxBytes, 126 | TxBytes: txBytes, 127 | RxPackets: rxPackets, 128 | TxPackets: txPackets, 129 | }, nil 130 | } 131 | } 132 | } 133 | 134 | return nil, fmt.Errorf("interface %s not found in /proc/net/dev", interfaceName) 135 | } 136 | 137 | // getInterfaceSpeed reads interface speed from sysfs 138 | func getInterfaceSpeed(interfaceName string) (string, error) { 139 | speedPath := fmt.Sprintf("/sys/class/net/%s/speed", interfaceName) 140 | data, err := os.ReadFile(speedPath) 141 | if err != nil { 142 | return "Unknown", nil // Not all interfaces have speed (e.g., virtual ones) 143 | } 144 | 145 | speed := strings.TrimSpace(string(data)) 146 | if speed == "-1" { 147 | return "Unknown", nil 148 | } 149 | 150 | speedInt, err := strconv.Atoi(speed) 151 | if err != nil { 152 | return "Unknown", nil 153 | } 154 | 155 | if speedInt >= 1000 { 156 | return fmt.Sprintf("%.1f Gbps", float64(speedInt)/1000), nil 157 | } 158 | return fmt.Sprintf("%d Mbps", speedInt), nil 159 | } -------------------------------------------------------------------------------- /roadmap.md: -------------------------------------------------------------------------------- 1 | roadmap 2 | 1.28.0: 3 | 4 | • PXE install flow 5 | • noVNC integration 6 | • Firewall feature via libvirt 7 | -------------------------------------------------------------------------------- /server/handlers_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "github.com/ccheshirecat/flint/pkg/core" 5 | "testing" 6 | ) 7 | 8 | func TestValidateVMCreationConfig(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | config core.VMCreationConfig 12 | wantErr bool 13 | }{ 14 | { 15 | name: "valid config", 16 | config: core.VMCreationConfig{ 17 | Name: "test-vm", 18 | MemoryMB: 2048, 19 | VCPUs: 2, 20 | ImageName: "ubuntu-24.04", 21 | ImageType: "template", 22 | DiskSizeGB: 20, 23 | }, 24 | wantErr: false, 25 | }, 26 | { 27 | name: "empty name", 28 | config: core.VMCreationConfig{ 29 | Name: "", 30 | MemoryMB: 2048, 31 | VCPUs: 2, 32 | ImageName: "ubuntu-24.04", 33 | DiskSizeGB: 20, 34 | }, 35 | wantErr: true, 36 | }, 37 | { 38 | name: "invalid name characters", 39 | config: core.VMCreationConfig{ 40 | Name: "test vm@", 41 | MemoryMB: 2048, 42 | VCPUs: 2, 43 | ImageName: "ubuntu-24.04", 44 | DiskSizeGB: 20, 45 | }, 46 | wantErr: true, 47 | }, 48 | { 49 | name: "zero memory", 50 | config: core.VMCreationConfig{ 51 | Name: "test-vm", 52 | MemoryMB: 0, 53 | VCPUs: 2, 54 | ImageName: "ubuntu-24.04", 55 | DiskSizeGB: 20, 56 | }, 57 | wantErr: true, 58 | }, 59 | { 60 | name: "excessive memory", 61 | config: core.VMCreationConfig{ 62 | Name: "test-vm", 63 | MemoryMB: 600000, // 600 GB 64 | VCPUs: 2, 65 | ImageName: "ubuntu-24.04", 66 | DiskSizeGB: 20, 67 | }, 68 | wantErr: true, 69 | }, 70 | { 71 | name: "zero vcpus", 72 | config: core.VMCreationConfig{ 73 | Name: "test-vm", 74 | MemoryMB: 2048, 75 | VCPUs: 0, 76 | ImageName: "ubuntu-24.04", 77 | DiskSizeGB: 20, 78 | }, 79 | wantErr: true, 80 | }, 81 | { 82 | name: "excessive vcpus", 83 | config: core.VMCreationConfig{ 84 | Name: "test-vm", 85 | MemoryMB: 2048, 86 | VCPUs: 200, 87 | ImageName: "ubuntu-24.04", 88 | DiskSizeGB: 20, 89 | }, 90 | wantErr: true, 91 | }, 92 | { 93 | name: "empty image name", 94 | config: core.VMCreationConfig{ 95 | Name: "test-vm", 96 | MemoryMB: 2048, 97 | VCPUs: 2, 98 | ImageName: "", 99 | DiskSizeGB: 20, 100 | }, 101 | wantErr: true, 102 | }, 103 | { 104 | name: "invalid image type", 105 | config: core.VMCreationConfig{ 106 | Name: "test-vm", 107 | MemoryMB: 2048, 108 | VCPUs: 2, 109 | ImageName: "ubuntu-24.04", 110 | ImageType: "invalid", 111 | DiskSizeGB: 20, 112 | }, 113 | wantErr: true, 114 | }, 115 | } 116 | 117 | for _, tt := range tests { 118 | t.Run(tt.name, func(t *testing.T) { 119 | err := validateVMCreationConfig(&tt.config) 120 | if (err != nil) != tt.wantErr { 121 | t.Errorf("validateVMCreationConfig() error = %v, wantErr %v", err, tt.wantErr) 122 | } 123 | }) 124 | } 125 | } 126 | 127 | func TestValidateUUID(t *testing.T) { 128 | tests := []struct { 129 | name string 130 | uuid string 131 | wantErr bool 132 | }{ 133 | { 134 | name: "valid UUID", 135 | uuid: "550e8400-e29b-41d4-a716-446655440000", 136 | wantErr: false, 137 | }, 138 | { 139 | name: "empty UUID", 140 | uuid: "", 141 | wantErr: true, 142 | }, 143 | { 144 | name: "invalid format", 145 | uuid: "invalid-uuid", 146 | wantErr: true, 147 | }, 148 | { 149 | name: "too short", 150 | uuid: "550e8400-e29b-41d4-a716", 151 | wantErr: true, 152 | }, 153 | } 154 | 155 | for _, tt := range tests { 156 | t.Run(tt.name, func(t *testing.T) { 157 | err := validateUUID(tt.uuid) 158 | if (err != nil) != tt.wantErr { 159 | t.Errorf("validateUUID() error = %v, wantErr %v", err, tt.wantErr) 160 | } 161 | }) 162 | } 163 | } 164 | 165 | func TestValidateFilePath(t *testing.T) { 166 | tests := []struct { 167 | name string 168 | path string 169 | wantErr bool 170 | }{ 171 | { 172 | name: "valid absolute path", 173 | path: "/var/lib/images/ubuntu.iso", 174 | wantErr: false, 175 | }, 176 | { 177 | name: "valid relative path", 178 | path: "./images/ubuntu.iso", 179 | wantErr: false, 180 | }, 181 | { 182 | name: "empty path", 183 | path: "", 184 | wantErr: true, 185 | }, 186 | { 187 | name: "directory traversal", 188 | path: "../../../etc/passwd", 189 | wantErr: true, 190 | }, 191 | { 192 | name: "too long path", 193 | path: string(make([]byte, 5000)), // Very long path 194 | wantErr: true, 195 | }, 196 | } 197 | 198 | for _, tt := range tests { 199 | t.Run(tt.name, func(t *testing.T) { 200 | err := validateFilePath(tt.path) 201 | if (err != nil) != tt.wantErr { 202 | t.Errorf("validateFilePath() error = %v, wantErr %v", err, tt.wantErr) 203 | } 204 | }) 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /server/server_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "embed" 5 | "github.com/ccheshirecat/flint/pkg/libvirtclient" 6 | "testing" 7 | ) 8 | 9 | //go:embed testdata/* 10 | var testAssets embed.FS 11 | 12 | func TestServer_GetAPIKey(t *testing.T) { 13 | // Create a mock client (we'll need to implement a mock for testing) 14 | // For now, just test that the method exists and returns a string 15 | client, err := libvirtclient.NewClient("test:///default", "isos", "templates") 16 | if err != nil { 17 | t.Skip("Skipping test: libvirt not available in test environment") 18 | } 19 | defer client.Close() 20 | 21 | server := NewServer(client, testAssets) 22 | apiKey := server.GetAPIKey() 23 | 24 | // API key should be a non-empty string 25 | if apiKey == "" { 26 | t.Error("GetAPIKey() returned empty string") 27 | } 28 | 29 | // API key should be 64 characters (32 bytes hex encoded) 30 | if len(apiKey) != 64 { 31 | t.Errorf("GetAPIKey() returned string of length %d, expected 64", len(apiKey)) 32 | } 33 | } 34 | 35 | func TestValidateAuthToken(t *testing.T) { 36 | // Create a test server 37 | client, err := libvirtclient.NewClient("test:///default", "isos", "templates") 38 | if err != nil { 39 | t.Skip("Skipping test: libvirt not available in test environment") 40 | } 41 | defer client.Close() 42 | 43 | server := NewServer(client, testAssets) 44 | 45 | tests := []struct { 46 | name string 47 | token string 48 | valid bool 49 | }{ 50 | { 51 | name: "valid token", 52 | token: server.GetAPIKey(), 53 | valid: true, 54 | }, 55 | { 56 | name: "empty token", 57 | token: "", 58 | valid: false, 59 | }, 60 | { 61 | name: "wrong length", 62 | token: "short", 63 | valid: false, 64 | }, 65 | { 66 | name: "invalid hex", 67 | token: "zzzz8400e29b41d4a716446655440000e29b41d4a716446655440000e29b41d4a716", 68 | valid: false, 69 | }, 70 | } 71 | 72 | for _, tt := range tests { 73 | t.Run(tt.name, func(t *testing.T) { 74 | result := server.validateAuthToken(tt.token) 75 | if result != tt.valid { 76 | t.Errorf("validateAuthToken() = %v, want %v", result, tt.valid) 77 | } 78 | }) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /server/testdata/test.txt: -------------------------------------------------------------------------------- 1 | test file for embed.FS -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # next.js 7 | /.next/ 8 | /out/ 9 | 10 | 11 | /build 12 | 13 | # debug 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | .pnpm-debug.log* 18 | 19 | # env files 20 | .env* 21 | 22 | 23 | # typescript 24 | *.tsbuildinfo 25 | next-env.d.ts -------------------------------------------------------------------------------- /web/app/analytics/page.tsx: -------------------------------------------------------------------------------- 1 | import { AppShell } from "@/components/app-shell" 2 | import { AnalyticsView } from "@/components/analytics-view" 3 | 4 | export default function AnalyticsPage() { 5 | return ( 6 | 7 | 8 | 9 | ) 10 | } -------------------------------------------------------------------------------- /web/app/images/page.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useState, useEffect } from "react" 4 | import { AppShell } from "@/components/app-shell" 5 | import { ImagesView } from "@/components/images-view" 6 | import { ImageRepository } from "@/components/image-repository" 7 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" 8 | import { HardDrive, Cloud } from "lucide-react" 9 | import { SPACING, TYPOGRAPHY } from "@/lib/ui-constants" 10 | 11 | export default function ImagesPage() { 12 | const [activeTab, setActiveTab] = useState("my-images") 13 | 14 | // Handle URL hash for direct navigation 15 | useEffect(() => { 16 | const hash = window.location.hash.replace('#', '') 17 | if (hash === 'repository') { 18 | setActiveTab('repository') 19 | } else if (hash === 'my-images') { 20 | setActiveTab('my-images') 21 | } 22 | }, []) 23 | 24 | const handleTabChange = (value: string) => { 25 | setActiveTab(value) 26 | // Update URL hash for better navigation 27 | window.history.replaceState(null, '', `#${value}`) 28 | } 29 | 30 | return ( 31 | 32 |
33 | {/* Page Header */} 34 |
35 |
36 |

Images

37 |

Manage virtual machine images and cloud repository

38 |
39 |
40 | 41 | 42 | 43 | 44 | 45 | My Images 46 | 47 | 48 | 49 | Cloud Repository 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 |
62 |
63 | ) 64 | } -------------------------------------------------------------------------------- /web/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { GeistSans } from "geist/font/sans"; 3 | import { GeistMono } from "geist/font/mono"; 4 | import { Inter } from "next/font/google"; 5 | import { ThemeProvider } from "@/components/theme-provider"; 6 | import { Toaster } from "@/components/ui/toaster"; 7 | import { Suspense } from "react"; 8 | import "./globals.css" 9 | import "../styles/static-export-fixes.css"; 10 | 11 | const inter = Inter({ 12 | subsets: ["latin"], 13 | variable: "--font-inter", 14 | weight: ["400", "500", "600", "700"], 15 | }); 16 | 17 | export const metadata: Metadata = { 18 | title: { 19 | default: "Flint - Premium KVM Virtualization Platform", 20 | template: "%s | Flint", 21 | }, 22 | description: "Premium KVM management for enterprise-grade virtualization. Ignite your infrastructure with Flint.", 23 | icons: { 24 | icon: [ 25 | { url: "/flint.svg", type: "image/svg+xml" }, 26 | { url: "/favicon-16x16.png", sizes: "16x16", type: "image/png" }, 27 | { url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" }, 28 | { url: "/android-chrome-192x192.png", sizes: "192x192", type: "image/png" }, 29 | { url: "/android-chrome-512x512.png", sizes: "512x512", type: "image/png" }, 30 | ], 31 | apple: "/apple-touch-icon.png", 32 | shortcut: "/favicon.ico", 33 | }, 34 | manifest: "/site.webmanifest", 35 | }; 36 | 37 | export default function RootLayout({ 38 | children, 39 | }: Readonly<{ 40 | children: React.ReactNode; 41 | }>) { 42 | return ( 43 | 44 | 55 | 57 |
58 |
59 | Loading Flint... 60 |
61 |
62 | }> 63 | 69 | {children} 70 | 71 | 72 |
73 | 74 | 75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /web/app/networking/page.tsx: -------------------------------------------------------------------------------- 1 | import { AppShell } from "@/components/app-shell" 2 | import { EnhancedNetworkingView } from "@/components/enhanced-networking-view" 3 | 4 | export default function NetworkingPage() { 5 | return ( 6 | 7 | 8 | 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /web/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { AppShell } from "@/components/app-shell" 2 | import { DashboardView } from "@/components/dashboard-view" 3 | 4 | export default function HomePage() { 5 | return ( 6 | 7 | 8 | 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /web/app/settings/page.tsx: -------------------------------------------------------------------------------- 1 | import { AppShell } from "@/components/app-shell" 2 | import { SettingsView } from "@/components/settings-view" 3 | 4 | export default function SettingsPage() { 5 | return ( 6 | 7 | 8 | 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /web/app/storage/page.tsx: -------------------------------------------------------------------------------- 1 | import { AppShell } from "@/components/app-shell" 2 | import { StorageView } from "@/components/storage-view" 3 | 4 | export default function StoragePage() { 5 | return ( 6 | 7 | 8 | 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /web/app/vms/console/page.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import dynamic from 'next/dynamic'; 3 | import { PageLayout } from '@/components/shared/page-layout'; 4 | import { getUrlParams, navigateTo, routes } from '@/lib/navigation'; 5 | import { Button } from '@/components/ui/button'; 6 | import { ArrowLeft } from 'lucide-react'; 7 | 8 | const VMSerialConsole = dynamic( 9 | () => import('@/components/vm-serial-console').then(mod => mod.VMSerialConsole), 10 | { ssr: false } 11 | ); 12 | 13 | export default function ConsolePage() { 14 | const searchParams = getUrlParams(); 15 | 16 | const vmUuid = searchParams.get('id'); // ✅ extract vmUuid safely 17 | 18 | if (!vmUuid) { 19 | return ( 20 | 24 |
25 |
26 |

No VM Selected

27 |

Please select a VM to access its console

28 | 32 |
33 |
34 |
35 | ); 36 | } 37 | 38 | return ( 39 | navigateTo(routes.vmDetail(vmUuid))} 46 | > 47 | 48 | Back to VM Details 49 | 50 | } 51 | > 52 | 53 | 54 | ); 55 | } -------------------------------------------------------------------------------- /web/app/vms/create/page.tsx: -------------------------------------------------------------------------------- 1 | import { AppShell } from "@/components/app-shell" 2 | import { SimpleVMWizard } from "@/components/simple-vm-wizard" 3 | 4 | export default function CreateVMPage() { 5 | return ( 6 | 7 | 8 | 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /web/app/vms/detail/page.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import dynamic from 'next/dynamic'; 3 | import { PageLayout } from '@/components/shared/page-layout'; 4 | import { ErrorBoundary } from '@/components/error-boundary'; 5 | 6 | const VMDetailView = dynamic( 7 | () => import('@/components/vm-detail-view').then(mod => mod.VMDetailView), 8 | { ssr: false } 9 | ); 10 | 11 | export default function VMDetailPage() { 12 | return ( 13 | 17 | 18 | 19 | 20 | 21 | ); 22 | } -------------------------------------------------------------------------------- /web/app/vms/page.tsx: -------------------------------------------------------------------------------- 1 | import { AppShell } from "@/components/app-shell" 2 | import { VirtualMachineListView } from "@/components/virtual-machine-list-view" 3 | 4 | export default function VirtualMachinesPage() { 5 | return ( 6 | 7 | 8 | 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /web/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /web/components/charts/gauge-chart.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" 4 | 5 | interface GaugeChartProps { 6 | value: number 7 | max: number 8 | title: string 9 | unit?: string 10 | color?: string 11 | icon?: React.ReactNode 12 | } 13 | 14 | export function GaugeChart({ 15 | value, 16 | max, 17 | title, 18 | unit = "", 19 | color = "hsl(var(--primary))", 20 | icon 21 | }: GaugeChartProps) { 22 | const percentage = Math.min(100, Math.max(0, (value / max) * 100)) 23 | 24 | // Determine color based on percentage 25 | let gaugeColor = color 26 | if (percentage > 80) { 27 | gaugeColor = "hsl(var(--destructive))" 28 | } else if (percentage > 60) { 29 | gaugeColor = "hsl(var(--warning))" 30 | } 31 | 32 | return ( 33 | 34 | 35 | 36 | {icon} 37 | {title} 38 | 39 | 40 | 41 |
42 | 43 | {/* Background circle */} 44 | 52 | {/* Progress circle */} 53 | 65 | 66 |
67 | {value}{unit} 68 | {percentage.toFixed(0)}% 69 |
70 |
71 |
72 |
79 |
80 | 81 | 82 | ) 83 | } -------------------------------------------------------------------------------- /web/components/charts/multi-series-chart.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { 4 | LineChart, 5 | Line, 6 | XAxis, 7 | YAxis, 8 | CartesianGrid, 9 | Tooltip, 10 | ResponsiveContainer, 11 | Legend 12 | } from 'recharts' 13 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" 14 | import { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent } from "@/components/ui/chart" 15 | 16 | interface SeriesConfig { 17 | dataKey: string 18 | name: string 19 | color: string 20 | } 21 | 22 | interface MultiSeriesChartProps { 23 | data: any[] 24 | title: string 25 | series: SeriesConfig[] 26 | unit?: string 27 | icon?: React.ReactNode 28 | } 29 | 30 | export function MultiSeriesChart({ 31 | data, 32 | title, 33 | series, 34 | unit = "", 35 | icon 36 | }: MultiSeriesChartProps) { 37 | // Format data for display 38 | const formattedData = data.map(item => { 39 | const formattedItem: any = { ...item } 40 | series.forEach(s => { 41 | formattedItem[s.dataKey] = unit === "%" ? parseFloat(item[s.dataKey]) : item[s.dataKey] 42 | }) 43 | return formattedItem 44 | }) 45 | 46 | const chartConfig = series.reduce((acc, s) => ({ 47 | ...acc, 48 | [s.dataKey]: { 49 | label: s.name, 50 | color: s.color, 51 | } 52 | }), {}) 53 | 54 | return ( 55 | 56 | 57 | 58 | {icon} 59 | {title} 60 | 61 | 62 | 63 | 64 | 65 | 69 | 70 | 75 | `${value}${unit}`} 80 | /> 81 | [`${value}${unit}`, '']} 86 | /> 87 | } 88 | /> 89 | } /> 90 | {series.map((s, index) => ( 91 | 101 | ))} 102 | 103 | 104 | 105 | 106 | 107 | ) 108 | } -------------------------------------------------------------------------------- /web/components/charts/performance-chart.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { 4 | LineChart, 5 | Line, 6 | XAxis, 7 | YAxis, 8 | CartesianGrid, 9 | Tooltip, 10 | ResponsiveContainer, 11 | Legend 12 | } from 'recharts' 13 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" 14 | import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart" 15 | 16 | interface PerformanceChartProps { 17 | data: any[] 18 | title: string 19 | dataKey: string 20 | color: string 21 | unit?: string 22 | icon?: React.ReactNode 23 | } 24 | 25 | export function PerformanceChart({ 26 | data, 27 | title, 28 | dataKey, 29 | color, 30 | unit = "", 31 | icon 32 | }: PerformanceChartProps) { 33 | // Format data for display 34 | const formattedData = data.map(item => ({ 35 | ...item, 36 | [dataKey]: unit === "%" ? parseFloat(item[dataKey]) : item[dataKey] 37 | })) 38 | 39 | return ( 40 | 41 | 42 | 43 | {icon} 44 | {title} 45 | 46 | 47 | 48 | 54 | 55 | 59 | 60 | 65 | `${value}${unit}`} 70 | /> 71 | [`${value}${unit}`, title]} 76 | /> 77 | } 78 | /> 79 | 87 | 88 | 89 | 90 | 91 | 92 | ) 93 | } -------------------------------------------------------------------------------- /web/components/error-boundary.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import React from "react" 4 | import { Button } from "@/components/ui/button" 5 | import { refreshPage } from "@/lib/navigation" 6 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" 7 | import { AlertTriangle, RefreshCw } from "lucide-react" 8 | 9 | interface ErrorBoundaryState { 10 | hasError: boolean 11 | error?: Error 12 | } 13 | 14 | interface ErrorBoundaryProps { 15 | children: React.ReactNode 16 | fallback?: React.ComponentType<{ error?: Error; resetError: () => void }> 17 | } 18 | 19 | export class ErrorBoundary extends React.Component { 20 | constructor(props: ErrorBoundaryProps) { 21 | super(props) 22 | this.state = { hasError: false } 23 | } 24 | 25 | static getDerivedStateFromError(error: Error): ErrorBoundaryState { 26 | return { hasError: true, error } 27 | } 28 | 29 | componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { 30 | console.error('Error boundary caught an error:', error, errorInfo) 31 | } 32 | 33 | resetError = () => { 34 | this.setState({ hasError: false, error: undefined }) 35 | } 36 | 37 | render() { 38 | if (this.state.hasError) { 39 | if (this.props.fallback) { 40 | const FallbackComponent = this.props.fallback 41 | return 42 | } 43 | 44 | return 45 | } 46 | 47 | return this.props.children 48 | } 49 | } 50 | 51 | interface DefaultErrorFallbackProps { 52 | error?: Error 53 | resetError: () => void 54 | } 55 | 56 | function DefaultErrorFallback({ error, resetError }: DefaultErrorFallbackProps) { 57 | return ( 58 |
59 | 60 | 61 |
62 | 63 |
64 | Something went wrong 65 | 66 | An unexpected error occurred while loading this component. 67 | 68 |
69 | 70 | {error && ( 71 |
72 |

73 | {error.message} 74 |

75 |
76 | )} 77 |
78 | 82 | 89 |
90 |
91 |
92 |
93 | ) 94 | } -------------------------------------------------------------------------------- /web/components/settings-view.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import type React from "react" 4 | import { useState, useEffect } from "react" 5 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" 6 | import { Label } from "@/components/ui/label" 7 | import { Input } from "@/components/ui/input" 8 | import { Monitor } from "lucide-react" 9 | import { hostAPI } from "@/lib/api" 10 | 11 | interface SystemInfo { 12 | hostname: string 13 | cpuCores: number 14 | totalMemory: string 15 | storagePath: string 16 | } 17 | 18 | export function SettingsView() { 19 | const [systemInfo, setSystemInfo] = useState({ 20 | hostname: "localhost", 21 | cpuCores: 0, 22 | totalMemory: "0 GB", 23 | storagePath: "/var/lib/libvirt", 24 | }) 25 | 26 | const [isLoading, setIsLoading] = useState(true) 27 | 28 | useEffect(() => { 29 | const fetchSystemInfo = async () => { 30 | try { 31 | const status = await hostAPI.getStatus() 32 | const resources = await hostAPI.getResources() 33 | 34 | setSystemInfo({ 35 | hostname: status.hostname || "localhost", 36 | cpuCores: resources.cpu_cores, 37 | totalMemory: `${(resources.total_memory_kb / 1024 / 1024).toFixed(1)} GB`, 38 | storagePath: "/var/lib/libvirt", // Default path 39 | }) 40 | } catch (error) { 41 | console.error("Failed to fetch system info:", error) 42 | } finally { 43 | setIsLoading(false) 44 | } 45 | } 46 | 47 | fetchSystemInfo() 48 | }, []) 49 | 50 | if (isLoading) { 51 | return ( 52 |
53 |
54 |
55 |

Loading system information...

56 |
57 |
58 |
59 | ) 60 | } 61 | 62 | return ( 63 |
64 |
65 |
66 |

System Information

67 |

Hardware and system details

68 |
69 |
70 | 71 |
72 | {/* System Information */} 73 | 74 | 75 | 76 | 77 | System Information 78 | 79 | Hardware and system details 80 | 81 | 82 |
83 |
84 | 85 | 90 |
91 |
92 | 93 | 98 |
99 |
100 | 101 | 106 |
107 |
108 | 109 | 114 |
115 |
116 |
117 |
118 |
119 |
120 | ) 121 | } 122 | -------------------------------------------------------------------------------- /web/components/shared/activity-feed.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" 2 | import { Separator } from "@/components/ui/separator" 3 | 4 | interface Activity { 5 | action: string 6 | target: string 7 | time: string 8 | user: string 9 | } 10 | 11 | interface ActivityFeedProps { 12 | activities: Activity[] 13 | title?: string 14 | } 15 | 16 | export function ActivityFeed({ activities, title = "Recent Activity" }: ActivityFeedProps) { 17 | return ( 18 | 19 | 20 | {title} 21 | 22 | 23 | {activities.map((activity, index) => ( 24 |
25 |
26 |
27 |
28 |

{activity.action}

29 |

{activity.target}

30 |
31 | {activity.time} 32 | by {activity.user} 33 |
34 |
35 |
36 | {index < activities.length - 1 && } 37 |
38 | ))} 39 | 40 | 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /web/components/shared/page-layout.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react" 2 | import { AppShell } from "@/components/app-shell" 3 | import { cn } from "@/lib/utils" 4 | 5 | interface PageLayoutProps { 6 | children: React.ReactNode 7 | title?: string 8 | description?: string 9 | actions?: React.ReactNode 10 | } 11 | 12 | export function PageLayout({ children, title, description, actions }: PageLayoutProps) { 13 | return ( 14 | 15 | {(title || description || actions) && ( 16 |
17 |
18 | {(title || description) && ( 19 |
20 | {title &&

{title}

} 21 | {description &&

{description}

} 22 |
23 | )} 24 | {actions &&
{actions}
} 25 |
26 |
27 | )} 28 |
29 | {children} 30 |
31 |
32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /web/components/shared/quick-actions.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import type React from "react" 4 | import { navigateTo, routes } from "@/lib/navigation" 5 | 6 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" 7 | import { Button } from "@/components/ui/button" 8 | import { Plus, HardDrive, Network, TrendingUp } from "lucide-react" 9 | 10 | interface QuickAction { 11 | label: string 12 | icon: React.ReactNode 13 | onClick: () => void 14 | } 15 | 16 | interface QuickActionsProps { 17 | actions?: QuickAction[] 18 | title?: string 19 | } 20 | 21 | 22 | 23 | export function QuickActions({ actions, title = "Quick Actions" }: QuickActionsProps) { 24 | 25 | const handleAction = (action: QuickAction) => { 26 | switch (action.label) { 27 | case "Create New VM": 28 | navigateTo(routes.vmCreate) 29 | break 30 | case "Add Storage Pool": 31 | navigateTo(routes.storage) 32 | break 33 | case "Configure Network": 34 | navigateTo(routes.networking) 35 | break 36 | case "View Performance": 37 | navigateTo(routes.analytics) 38 | break 39 | default: 40 | action.onClick() 41 | } 42 | } 43 | 44 | const finalActions = actions || [ 45 | { 46 | label: "Create New VM", 47 | icon: , 48 | onClick: () => navigateTo(routes.vmCreate), 49 | }, 50 | { 51 | label: "Add Storage Pool", 52 | icon: , 53 | onClick: () => navigateTo(routes.storage), 54 | }, 55 | { 56 | label: "Configure Network", 57 | icon: , 58 | onClick: () => navigateTo(routes.networking), 59 | }, 60 | { 61 | label: "View Performance", 62 | icon: , 63 | onClick: () => navigateTo(routes.analytics), 64 | }, 65 | ] 66 | 67 | return ( 68 | 69 | 70 | {title} 71 | 72 | 73 | {finalActions.map((action, index) => ( 74 | 83 | ))} 84 | 85 | 86 | ) 87 | } 88 | -------------------------------------------------------------------------------- /web/components/shared/resource-card.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react" 2 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" 3 | import { Progress } from "@/components/ui/progress" 4 | import { cn } from "@/lib/utils" 5 | 6 | interface ResourceCardProps { 7 | title: string 8 | value: string | number 9 | total?: string | number 10 | percentage?: number 11 | icon?: React.ReactNode 12 | trend?: "up" | "down" | "stable" 13 | trendValue?: string 14 | className?: string 15 | children?: React.ReactNode 16 | } 17 | 18 | export function ResourceCard({ 19 | title, 20 | value, 21 | total, 22 | percentage, 23 | icon, 24 | trend, 25 | trendValue, 26 | className, 27 | children, 28 | }: ResourceCardProps) { 29 | return ( 30 | 37 | 38 | {title} 39 | {icon &&
{icon}
} 40 |
41 | 42 |
43 | {value} 44 | {total && /{total}} 45 |
46 | {percentage !== undefined && ( 47 |
48 | 80 ? "[&>div]:bg-destructive" : percentage > 60 ? "[&>div]:bg-accent" : "[&>div]:bg-primary" 53 | )} 54 | /> 55 |

{Math.round(percentage)}% used

56 |
57 | )} 58 | {trend && trendValue && ( 59 |

69 | {trend === "up" ? "↗" : trend === "down" ? "↘" : "→"} 70 | {trendValue} 71 |

72 | )} 73 | {children} 74 |
75 |
76 | ) 77 | } 78 | -------------------------------------------------------------------------------- /web/components/shared/status-badge.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react" 2 | import { Badge } from "@/components/ui/badge" 3 | import { cn } from "@/lib/utils" 4 | 5 | export type StatusType = 6 | | "running" 7 | | "stopped" 8 | | "paused" 9 | | "error" 10 | | "warning" 11 | | "success" 12 | | "active" 13 | | "inactive" 14 | | "connected" 15 | | "disconnected" 16 | 17 | interface StatusBadgeProps { 18 | status: StatusType 19 | children: React.ReactNode 20 | className?: string 21 | } 22 | 23 | const statusStyles: Record = { 24 | running: "bg-primary text-primary-foreground border-primary/20", 25 | stopped: "bg-muted text-muted-foreground border-border/50", 26 | paused: "bg-accent text-accent-foreground border-accent/20", 27 | error: "bg-destructive text-destructive-foreground border-destructive/20", 28 | warning: "bg-accent text-accent-foreground border-accent/20", 29 | success: "bg-primary text-primary-foreground border-primary/20", 30 | active: "bg-primary text-primary-foreground border-primary/20", 31 | inactive: "bg-muted text-muted-foreground border-border/50", 32 | connected: "bg-primary text-primary-foreground border-primary/20", 33 | disconnected: "bg-destructive text-destructive-foreground border-destructive/20", 34 | } 35 | 36 | export function StatusBadge({ status, children, className }: StatusBadgeProps) { 37 | return ( 38 | 46 | {children} 47 | 48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /web/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import { 5 | ThemeProvider as NextThemesProvider, 6 | type ThemeProviderProps, 7 | } from 'next-themes' 8 | 9 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 10 | return {children} 11 | } 12 | -------------------------------------------------------------------------------- /web/components/theme-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import { Moon, Sun } from "lucide-react" 3 | import { useTheme } from "next-themes" 4 | 5 | import { Button } from "@/components/ui/button" 6 | 7 | export function ThemeToggle() { 8 | const { setTheme, theme } = useTheme() 9 | 10 | return ( 11 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /web/components/ui/accordion.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AccordionPrimitive from "@radix-ui/react-accordion" 5 | import { ChevronDownIcon } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | function Accordion({ 10 | ...props 11 | }: React.ComponentProps) { 12 | return 13 | } 14 | 15 | function AccordionItem({ 16 | className, 17 | ...props 18 | }: React.ComponentProps) { 19 | return ( 20 | 25 | ) 26 | } 27 | 28 | function AccordionTrigger({ 29 | className, 30 | children, 31 | ...props 32 | }: React.ComponentProps) { 33 | return ( 34 | 35 | svg]:rotate-180", 39 | className 40 | )} 41 | {...props} 42 | > 43 | {children} 44 | 45 | 46 | 47 | ) 48 | } 49 | 50 | function AccordionContent({ 51 | className, 52 | children, 53 | ...props 54 | }: React.ComponentProps) { 55 | return ( 56 | 61 |
{children}
62 |
63 | ) 64 | } 65 | 66 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } 67 | -------------------------------------------------------------------------------- /web/components/ui/alert-dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" 5 | 6 | import { cn } from "@/lib/utils" 7 | import { buttonVariants } from "@/components/ui/button" 8 | 9 | function AlertDialog({ 10 | ...props 11 | }: React.ComponentProps) { 12 | return 13 | } 14 | 15 | function AlertDialogTrigger({ 16 | ...props 17 | }: React.ComponentProps) { 18 | return ( 19 | 20 | ) 21 | } 22 | 23 | function AlertDialogPortal({ 24 | ...props 25 | }: React.ComponentProps) { 26 | return ( 27 | 28 | ) 29 | } 30 | 31 | function AlertDialogOverlay({ 32 | className, 33 | ...props 34 | }: React.ComponentProps) { 35 | return ( 36 | 44 | ) 45 | } 46 | 47 | function AlertDialogContent({ 48 | className, 49 | ...props 50 | }: React.ComponentProps) { 51 | return ( 52 | 53 | 54 | 62 | 63 | ) 64 | } 65 | 66 | function AlertDialogHeader({ 67 | className, 68 | ...props 69 | }: React.ComponentProps<"div">) { 70 | return ( 71 |
76 | ) 77 | } 78 | 79 | function AlertDialogFooter({ 80 | className, 81 | ...props 82 | }: React.ComponentProps<"div">) { 83 | return ( 84 |
92 | ) 93 | } 94 | 95 | function AlertDialogTitle({ 96 | className, 97 | ...props 98 | }: React.ComponentProps) { 99 | return ( 100 | 105 | ) 106 | } 107 | 108 | function AlertDialogDescription({ 109 | className, 110 | ...props 111 | }: React.ComponentProps) { 112 | return ( 113 | 118 | ) 119 | } 120 | 121 | function AlertDialogAction({ 122 | className, 123 | ...props 124 | }: React.ComponentProps) { 125 | return ( 126 | 130 | ) 131 | } 132 | 133 | function AlertDialogCancel({ 134 | className, 135 | ...props 136 | }: React.ComponentProps) { 137 | return ( 138 | 142 | ) 143 | } 144 | 145 | export { 146 | AlertDialog, 147 | AlertDialogPortal, 148 | AlertDialogOverlay, 149 | AlertDialogTrigger, 150 | AlertDialogContent, 151 | AlertDialogHeader, 152 | AlertDialogFooter, 153 | AlertDialogTitle, 154 | AlertDialogDescription, 155 | AlertDialogAction, 156 | AlertDialogCancel, 157 | } 158 | -------------------------------------------------------------------------------- /web/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-card text-card-foreground", 12 | destructive: 13 | "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ) 21 | 22 | function Alert({ 23 | className, 24 | variant, 25 | ...props 26 | }: React.ComponentProps<"div"> & VariantProps) { 27 | return ( 28 |
34 | ) 35 | } 36 | 37 | function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { 38 | return ( 39 |
47 | ) 48 | } 49 | 50 | function AlertDescription({ 51 | className, 52 | ...props 53 | }: React.ComponentProps<"div">) { 54 | return ( 55 |
63 | ) 64 | } 65 | 66 | export { Alert, AlertTitle, AlertDescription } 67 | -------------------------------------------------------------------------------- /web/components/ui/aspect-ratio.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" 4 | 5 | function AspectRatio({ 6 | ...props 7 | }: React.ComponentProps) { 8 | return 9 | } 10 | 11 | export { AspectRatio } 12 | -------------------------------------------------------------------------------- /web/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Avatar({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 21 | ) 22 | } 23 | 24 | function AvatarImage({ 25 | className, 26 | ...props 27 | }: React.ComponentProps) { 28 | return ( 29 | 34 | ) 35 | } 36 | 37 | function AvatarFallback({ 38 | className, 39 | ...props 40 | }: React.ComponentProps) { 41 | return ( 42 | 50 | ) 51 | } 52 | 53 | export { Avatar, AvatarImage, AvatarFallback } 54 | -------------------------------------------------------------------------------- /web/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const badgeVariants = cva( 8 | "inline-flex items-center justify-center rounded-md border px-2.5 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "border-transparent bg-primary text-primary-foreground shadow-xs hover:bg-primary/80", 14 | secondary: 15 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 16 | destructive: 17 | "border-transparent bg-destructive text-white shadow-xs hover:bg-destructive/80 focus:ring-destructive/30 dark:bg-destructive/60", 18 | outline: 19 | "text-foreground border border-input bg-background hover:bg-accent hover:text-accent-foreground", 20 | }, 21 | }, 22 | defaultVariants: { 23 | variant: "default", 24 | }, 25 | } 26 | ) 27 | 28 | function Badge({ 29 | className, 30 | variant, 31 | asChild = false, 32 | ...props 33 | }: React.ComponentProps<"span"> & 34 | VariantProps & { asChild?: boolean }) { 35 | const Comp = asChild ? Slot : "span" 36 | 37 | return ( 38 | 43 | ) 44 | } 45 | 46 | export { Badge, badgeVariants } -------------------------------------------------------------------------------- /web/components/ui/breadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { ChevronRight, MoreHorizontal } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { 8 | return