diff --git a/AGENTS.md b/AGENTS.md
index 0591c7f..9b4b5bb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,7 +2,7 @@
> **Purpose**: Memungkinkan AI agent berikutnya melanjutkan development tanpa mengulang atau merusak.
> **Last Updated**: 2026-07-07
-> **Current Phase**: Sprint 1 + 2 + 3 + 4 + 5 selesai, Sprint 6 berikutnya
+> **Current Phase**: Sprint 0—6 selesai (V2 HTMX feature complete)
---
@@ -20,7 +20,7 @@ hostkeeper/ (root — bersih untuk V2)
│ ├── go.mod module git.tukangketik.id/swanadiva/hostkeeper
│ ├── Makefile
│ └── PROJECT_STATE.md V1 full project state
-├── app/ ← (BELUM DIBUAT) V2 backend GoFiber + views + static
+├── app/ ← V2 backend GoFiber + views + static
├── mobile/ ← (BELUM DIBUAT) V2 mobile wrapper
├── template/ ← Referensi UI React (Lumina System) — hanya untuk desain
│ └── src/components/ 7 React screens
@@ -102,7 +102,7 @@ Alasan:
---
-## 4. V2 Status — Sprint 1 Complete
+## 4. V2 Status — Sprint 0—6 Complete
### ✅ Sudah Selesai
- Semua dokumentasi diupdate ke HTMX (10 file di `docs/`)
@@ -112,13 +112,11 @@ Alasan:
- Bug template sudah diidentifikasi (animasi CSS, w-4.5, error boundary, dll)
- **Sprint 0**: GoFiber v2 server + HTMX + Alpine.js + TailwindCSS v4 + custom CSS + layout + nav + health endpoint
- **Sprint 1**: Host model (8 mock hosts), dashboard CRUD, search/filter HTMX, grid/list Alpine toggle, add host modal (Alpine events), delete host (hx-delete), transfers panel
-
-### ❌ Belum Dimulai / In Progress
-- **Sprint 2 (DONE)**: Snippets + Keychain
-- **Sprint 3 (DONE)**: Settings + Brief
-- **Sprint 4 (DONE)**: SFTP Dual-Pane
-- **Sprint 5 (DONE)**: Terminal + xterm.js
-- Sprint 6: Storage Integration
+- **Sprint 2**: Snippets + Keychain CRUD, clipboard.js, utils.js
+- **Sprint 3**: Settings + Brief, tabs, toggles, danger zone
+- **Sprint 4**: SFTP Dual-Pane, breadcrumb, file icons by type, create/delete
+- **Sprint 5**: Terminal + xterm.js + WebSocket mock shell, tab management
+- **Sprint 6**: JSON file persistence (store package), `go.mod replace` directive for V1 crypto+knownhosts, model SSH fields, real SSH client for terminal (falls back to mock), dynamic host list from server
### 🐛 Bug Template (dari template/src/ — catatan saat implementasi)
1. **P0**: Missing CSS keyframes (fade-in, scale-up, slide-in) → tambah di `custom.css`
@@ -149,6 +147,15 @@ app/backend/
│ │ ├── host.go
│ │ ├── snippet.go
│ │ └── keychain.go
+│ ├── store/ ← JSON file persistence (store package)
+│ │ ├── store.go
+│ │ ├── host.go
+│ │ ├── snippet.go
+│ │ ├── key.go
+│ │ ├── settings.go
+│ │ └── devices.go
+│ ├── sshconn/ ← Real SSH client for terminal
+│ │ └── client.go
│ ├── template/ ← Template helper funcs
│ └── middleware/ ← View data injection
├── static/
@@ -156,7 +163,6 @@ app/backend/
│ ├── js/ ← htmx.min.js, alpine.min.js, utils.js, clipboard.js, terminal.js
│ └── xterm/ ← xterm.js lib
└── views/ ← Semua template flat (tidak ada subfolder partials/)
- ├── layout.html
├── nav.html
├── index.html
├── host_list.html
diff --git a/app/backend/go.mod b/app/backend/go.mod
index ebca2ca..3b7a4ba 100644
--- a/app/backend/go.mod
+++ b/app/backend/go.mod
@@ -3,26 +3,31 @@ module git.tukangketik.id/swanadiva/hostkeeper/v2
go 1.26.4
require (
+ git.tukangketik.id/swanadiva/hostkeeper v1.0.0
+ github.com/gofiber/contrib/websocket v1.3.4
github.com/gofiber/fiber/v2 v2.52.14
github.com/gofiber/template/html/v2 v2.1.3
)
require (
github.com/andybalholm/brotli v1.1.0 // indirect
+ github.com/clipperhouse/stringish v0.1.1 // indirect
+ github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/fasthttp/websocket v1.5.8 // indirect
- github.com/gofiber/contrib/websocket v1.3.4 // indirect
github.com/gofiber/template v1.8.3 // indirect
github.com/gofiber/utils v1.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
- github.com/mattn/go-runewidth v0.0.16 // indirect
- github.com/rivo/uniseg v0.2.0 // indirect
+ github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.52.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
- golang.org/x/net v0.33.0 // indirect
- golang.org/x/sys v0.28.0 // indirect
+ golang.org/x/crypto v0.53.0 // indirect
+ golang.org/x/net v0.55.0 // indirect
+ golang.org/x/sys v0.46.0 // indirect
)
+
+replace git.tukangketik.id/swanadiva/hostkeeper => ../../v1
diff --git a/app/backend/go.sum b/app/backend/go.sum
index 346aeeb..3f52587 100644
--- a/app/backend/go.sum
+++ b/app/backend/go.sum
@@ -1,5 +1,9 @@
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
+github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
+github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
+github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
+github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fasthttp/websocket v1.5.8 h1:k5DpirKkftIF/w1R8ZzjSgARJrs54Je9YJK37DL/Ah8=
@@ -23,29 +27,27 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
-github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
+github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
-github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 h1:KanIMPX0QdEdB4R3CiimCAbxFrhB3j7h0/OvpYGVQa8=
github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
-github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
-github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
github.com/valyala/fasthttp v1.52.0 h1:wqBQpxH71XW0e2g+Og4dzQM8pk34aFYlA1Ga8db7gU0=
github.com/valyala/fasthttp v1.52.0/go.mod h1:hf5C4QnVMkNXMspnsUlfM3WitlgYflyhHYoKol/szxQ=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
-golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
-golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
+golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
+golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
+golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
+golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
-golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
+golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/app/backend/internal/handler/dashboard.go b/app/backend/internal/handler/dashboard.go
index d219897..ee10bf6 100644
--- a/app/backend/internal/handler/dashboard.go
+++ b/app/backend/internal/handler/dashboard.go
@@ -3,9 +3,10 @@ package handler
import (
"github.com/gofiber/fiber/v2"
"git.tukangketik.id/swanadiva/hostkeeper/v2/internal/model"
+ "git.tukangketik.id/swanadiva/hostkeeper/v2/internal/store"
)
-var store = model.NewHostStore()
+var hostStore = store.NewHostStore()
var navItems = []fiber.Map{
{"ID": "hosts", "Label": "Hosts", "Icon": "server"},
@@ -17,7 +18,7 @@ var navItems = []fiber.Map{
}
func Dashboard(c *fiber.Ctx) error {
- hosts := store.All()
+ hosts := hostStore.All()
return c.Render("index", fiber.Map{
"View": "hosts",
"Title": "Hosts",
@@ -31,7 +32,7 @@ func Dashboard(c *fiber.Ctx) error {
func DashboardList(c *fiber.Ctx) error {
q := c.Query("q")
filter := c.Query("filter", "all")
- hosts := store.Search(q, filter)
+ hosts := hostStore.Search(q, filter)
return c.Render("host_list", fiber.Map{
"Hosts": hosts,
"Active": countByStatus(hosts, "active"),
@@ -50,8 +51,8 @@ func CreateHost(c *fiber.Ctx) error {
return c.Status(400).SendString("name and ip required")
}
- store.Add(name, ip, os, provider, hostType)
- hosts := store.All()
+ hostStore.Add(name, ip, os, provider, hostType)
+ hosts := hostStore.All()
return c.Render("host_list", fiber.Map{
"Hosts": hosts,
"Active": countByStatus(hosts, "active"),
@@ -61,8 +62,8 @@ func CreateHost(c *fiber.Ctx) error {
func DeleteHost(c *fiber.Ctx) error {
id := c.Params("id")
- store.Delete(id)
- hosts := store.All()
+ hostStore.Delete(id)
+ hosts := hostStore.All()
return c.Render("host_list", fiber.Map{
"Hosts": hosts,
"Active": countByStatus(hosts, "active"),
diff --git a/app/backend/internal/handler/keychain.go b/app/backend/internal/handler/keychain.go
index a8f7666..1bcdd9e 100644
--- a/app/backend/internal/handler/keychain.go
+++ b/app/backend/internal/handler/keychain.go
@@ -2,10 +2,10 @@ package handler
import (
"github.com/gofiber/fiber/v2"
- "git.tukangketik.id/swanadiva/hostkeeper/v2/internal/model"
+ "git.tukangketik.id/swanadiva/hostkeeper/v2/internal/store"
)
-var keyStore = model.NewKeyStore()
+var keyStore = store.NewKeyStore()
func Keychain(c *fiber.Ctx) error {
keys := keyStore.All()
diff --git a/app/backend/internal/handler/settings.go b/app/backend/internal/handler/settings.go
index 6ea6415..192fd13 100644
--- a/app/backend/internal/handler/settings.go
+++ b/app/backend/internal/handler/settings.go
@@ -3,23 +3,24 @@ package handler
import (
"github.com/gofiber/fiber/v2"
"git.tukangketik.id/swanadiva/hostkeeper/v2/internal/model"
+ "git.tukangketik.id/swanadiva/hostkeeper/v2/internal/store"
)
var deviceStore = model.NewDeviceStore()
-var configStore = model.NewConfigStore()
+var settingsStore = store.NewSettingsStore()
func Settings(c *fiber.Ctx) error {
return c.Render("settings", fiber.Map{
"View": "settings",
"Title": "Settings",
- "Config": configStore.Get(),
+ "Config": settingsStore.Get(),
"Devices": deviceStore.All(),
"NavItems": navItems,
})
}
func UpdateConfig(c *fiber.Ctx) error {
- cfg := configStore.Get()
+ cfg := settingsStore.Get()
if v := c.FormValue("theme"); v != "" { cfg.Theme = v }
if v := c.FormValue("fontSize"); v != "" { cfg.FontSize = parseInt(v, 14) }
if v := c.FormValue("scrollback"); v != "" { cfg.Scrollback = parseInt(v, 5000) }
@@ -29,7 +30,7 @@ func UpdateConfig(c *fiber.Ctx) error {
cfg.BellEnabled = c.FormValue("bellEnabled") == "on"
cfg.AutoReconnect = c.FormValue("autoReconnect") == "on"
cfg.BlinkCursor = c.FormValue("blinkCursor") == "on"
- configStore.Update(cfg)
+ settingsStore.Update(cfg)
return c.Redirect("/settings", 303)
}
diff --git a/app/backend/internal/handler/snippets.go b/app/backend/internal/handler/snippets.go
index 79e008b..bc38e92 100644
--- a/app/backend/internal/handler/snippets.go
+++ b/app/backend/internal/handler/snippets.go
@@ -2,10 +2,10 @@ package handler
import (
"github.com/gofiber/fiber/v2"
- "git.tukangketik.id/swanadiva/hostkeeper/v2/internal/model"
+ "git.tukangketik.id/swanadiva/hostkeeper/v2/internal/store"
)
-var snippetStore = model.NewSnippetStore()
+var snippetStore = store.NewSnippetStore()
func Snippets(c *fiber.Ctx) error {
snippets := snippetStore.All()
diff --git a/app/backend/internal/handler/terminal.go b/app/backend/internal/handler/terminal.go
index 56077fc..ddc40dd 100644
--- a/app/backend/internal/handler/terminal.go
+++ b/app/backend/internal/handler/terminal.go
@@ -1,39 +1,98 @@
package handler
import (
+ "bufio"
+ "encoding/json"
"fmt"
+ "io"
"log"
"strings"
"time"
"github.com/gofiber/contrib/websocket"
"github.com/gofiber/fiber/v2"
+ "git.tukangketik.id/swanadiva/hostkeeper/v2/internal/sshconn"
)
func Terminal(c *fiber.Ctx) error {
+ hosts := hostStore.All()
+ type hostItem struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ IP string `json:"ip"`
+ }
+ var available []hostItem
+ for _, h := range hosts {
+ if h.Status == "active" {
+ available = append(available, hostItem{h.ID, h.Name, h.IP})
+ }
+ }
+ hostsJSON, _ := json.Marshal(available)
return c.Render("terminal", fiber.Map{
- "View": "terminal",
- "Title": "Terminal",
- "NavItems": navItems,
+ "View": "terminal",
+ "Title": "Terminal",
+ "NavItems": navItems,
+ "Hosts": available,
+ "HostsJSON": string(hostsJSON),
})
}
-var hostList = []struct {
- ID string
- Name string
- IP string
-}{
- {"h1", "api-prod-01", "10.0.1.15"},
- {"h2", "db-primary-01", "10.0.2.5"},
- {"h6", "monitor-01", "10.0.0.5"},
- {"h7", "worker-pool-01", "10.0.4.50"},
+func TerminalWS(c *websocket.Conn) {
+ hostID := c.Params("host", "unknown")
+ log.Printf("WS connected: host=%s", hostID)
+
+ host, found := hostStore.Get(hostID)
+ if !found || host.Hostname == "" {
+ mockTerminal(c, hostID)
+ return
+ }
+
+ client, err := sshconn.Dial(host)
+ if err != nil {
+ log.Printf("SSH failed for %s: %v, falling back to mock", hostID, err)
+ mockTerminal(c, hostID)
+ return
+ }
+ defer client.Close()
+
+ welcome := fmt.Sprintf("\x1b[1;32mConnected to %s (%s)\x1b[0m\r\n", host.Name, host.IP)
+ c.WriteMessage(websocket.TextMessage, []byte(welcome))
+
+ stdinR, stdinW := io.Pipe()
+ stdoutR, stdoutW := io.Pipe()
+
+ go func() {
+ scanner := bufio.NewScanner(stdoutR)
+ for scanner.Scan() {
+ c.WriteMessage(websocket.TextMessage, []byte(scanner.Text()+"\r\n"))
+ }
+ }()
+
+ go func() {
+ defer stdinW.Close()
+ for {
+ _, msg, err := c.ReadMessage()
+ if err != nil {
+ return
+ }
+ input := string(msg)
+ if input == "\x03" {
+ client.Close()
+ return
+ }
+ stdinW.Write([]byte(input))
+ }
+ }()
+
+ if err := client.Shell(stdinR, stdoutW, stdoutW, 24, 80); err != nil {
+ log.Printf("SSH session ended: %v", err)
+ }
+
+ log.Printf("WS disconnected: host=%s", hostID)
}
-func TerminalWS(c *websocket.Conn) {
- host := c.Params("host", "unknown")
- log.Printf("WS connected: host=%s", host)
-
- welcome := fmt.Sprintf("\x1b[1;32mWelcome to %s\x1b[0m\r\n\x1b[2mConnected at %s\x1b[0m\r\n\r\n", host, time.Now().Format(time.RFC822))
+func mockTerminal(c *websocket.Conn, host string) {
+ welcome := fmt.Sprintf("\x1b[1;33m%s (mock mode)\x1b[0m\r\n\x1b[2mConnected at %s\x1b[0m\r\n\r\n", host, time.Now().Format(time.RFC822))
c.WriteMessage(websocket.TextMessage, []byte(welcome))
prompt := fmt.Sprintf("\x1b[1;34m%s:~$\x1b[0m ", host)
@@ -68,19 +127,15 @@ func TerminalWS(c *websocket.Conn) {
}
}
}
- log.Printf("WS disconnected: host=%s", host)
+ log.Printf("Mock WS disconnected: host=%s", host)
}
func handleCommand(cmd, host string, cwd *string) string {
cmd = strings.TrimSpace(cmd)
- if cmd == "" {
- return ""
- }
+ if cmd == "" { return "" }
parts := strings.Fields(cmd)
- if len(parts) == 0 {
- return ""
- }
+ if len(parts) == 0 { return "" }
switch parts[0] {
case "clear":
@@ -108,11 +163,7 @@ func handleCommand(cmd, host string, cwd *string) string {
}
return "total 0\r\n"
case "cd":
- if len(parts) > 1 {
- *cwd = parts[1]
- } else {
- *cwd = "~"
- }
+ if len(parts) > 1 { *cwd = parts[1] } else { *cwd = "~" }
return ""
case "cat":
if len(parts) > 1 {
diff --git a/app/backend/internal/model/host.go b/app/backend/internal/model/host.go
index c75bc21..99eff7c 100644
--- a/app/backend/internal/model/host.go
+++ b/app/backend/internal/model/host.go
@@ -1,100 +1,22 @@
package model
-import (
- "math/rand"
- "strings"
- "time"
-)
-
type Host struct {
ID string `json:"id"`
Name string `json:"name"`
IP string `json:"ip"`
OS string `json:"os"`
Provider string `json:"provider"`
- Status string `json:"status"` // active | offline
+ Status string `json:"status"`
LastSeen string `json:"lastSeen"`
- Type string `json:"type"` // api | db | edge | web | desktop | server
-}
+ Type string `json:"type"`
-type HostStore struct {
- Hosts []Host
-}
-
-func NewHostStore() *HostStore {
- return &HostStore{
- Hosts: defaultHosts(),
- }
-}
-
-func (s *HostStore) All() []Host {
- return s.Hosts
-}
-
-func (s *HostStore) Search(q, filter string) []Host {
- var result []Host
- for _, h := range s.Hosts {
- if filter != "" && filter != "all" && h.Status != filter {
- continue
- }
- if q == "" || strings.Contains(strings.ToLower(h.Name), strings.ToLower(q)) ||
- strings.Contains(strings.ToLower(h.IP), strings.ToLower(q)) ||
- strings.Contains(strings.ToLower(h.OS), strings.ToLower(q)) {
- result = append(result, h)
- }
- }
- if result == nil {
- return []Host{}
- }
- return result
-}
-
-func (s *HostStore) Delete(id string) {
- for i, h := range s.Hosts {
- if h.ID == id {
- s.Hosts = append(s.Hosts[:i], s.Hosts[i+1:]...)
- return
- }
- }
-}
-
-func (s *HostStore) Add(name, ip, os, provider, hostType string) Host {
- h := Host{
- ID: randString(8),
- Name: name,
- IP: ip,
- OS: os,
- Provider: provider,
- Status: "active",
- LastSeen: time.Now().Format("Jan 2, 2006"),
- Type: hostType,
- }
- s.Hosts = append(s.Hosts, h)
- return h
-}
-
-func contains(s, q string) bool {
- return strings.Contains(strings.ToLower(s), strings.ToLower(q))
-}
-
-func randString(n int) string {
- const letters = "abcdefghijklmnopqrstuvwxyz0123456789"
- b := make([]byte, n)
- for i := range b {
- b[i] = letters[rand.Intn(len(letters))]
- }
- return string(b)
-}
-
-func defaultHosts() []Host {
- return []Host{
- {ID: "h1", Name: "api-prod-01", IP: "10.0.1.15", OS: "Ubuntu 22.04", Provider: "AWS US-East", Status: "active", LastSeen: "Online", Type: "api"},
- {ID: "h2", Name: "db-primary-01", IP: "10.0.2.5", OS: "Debian 12", Provider: "AWS US-East", Status: "active", LastSeen: "Online", Type: "db"},
- {ID: "h3", Name: "web-edge-02", IP: "192.168.1.20", OS: "Alpine 3.19", Provider: "DigitalOcean", Status: "offline", LastSeen: "2 hours ago", Type: "edge"},
- {ID: "h4", Name: "staging-api-01", IP: "10.0.3.10", OS: "Ubuntu 22.04", Provider: "AWS EU-West", Status: "active", LastSeen: "5 min ago", Type: "api"},
- {ID: "h5", Name: "dev-db-02", IP: "192.168.1.45", OS: "Fedora 39", Provider: "Hetzner", Status: "offline", LastSeen: "1 day ago", Type: "db"},
- {ID: "h6", Name: "monitor-01", IP: "10.0.0.5", OS: "Ubuntu 24.04", Provider: "AWS US-East", Status: "active", LastSeen: "Just now", Type: "server"},
- {ID: "h7", Name: "worker-pool-01", IP: "10.0.4.50", OS: "Debian 12", Provider: "GCP US-Central", Status: "active", LastSeen: "1 min ago", Type: "server"},
- {ID: "h8", Name: "bastion-host", IP: "54.85.12.7", OS: "Ubuntu 22.04", Provider: "AWS US-East", Status: "offline", LastSeen: "3 days ago", Type: "server"},
- }
+ Hostname string `json:"hostname"`
+ Port int `json:"port"`
+ Username string `json:"username"`
+ AuthType string `json:"authType"` // password | key | both
+ Password string `json:"password,omitempty"`
+ KeyID string `json:"keyId,omitempty"`
+ Group string `json:"group,omitempty"`
+ Tags string `json:"tags,omitempty"`
+ Notes string `json:"notes,omitempty"`
}
diff --git a/app/backend/internal/model/keychain.go b/app/backend/internal/model/keychain.go
index f7ef729..2d23bb2 100644
--- a/app/backend/internal/model/keychain.go
+++ b/app/backend/internal/model/keychain.go
@@ -1,78 +1,11 @@
package model
-import "time"
-
type Key struct {
ID string `json:"id"`
Name string `json:"name"`
Username string `json:"username"`
URL string `json:"url"`
Passphrase string `json:"passphrase"`
- Strength string `json:"strength"` // weak | moderate | secure
+ Strength string `json:"strength"`
CreatedAt string `json:"createdAt"`
}
-
-type KeyStore struct {
- Keys []Key
-}
-
-func NewKeyStore() *KeyStore {
- return &KeyStore{Keys: defaultKeys()}
-}
-
-func (s *KeyStore) All() []Key { return s.Keys }
-func (s *KeyStore) Search(q string) []Key {
- if q == "" { return s.Keys }
- var res []Key
- for _, k := range s.Keys {
- if contains(k.Name, q) || contains(k.Username, q) || contains(k.URL, q) {
- res = append(res, k)
- }
- }
- return res
-}
-
-func (s *KeyStore) Add(name, username, url, passphrase string) Key {
- k := Key{
- ID: randString(8),
- Name: name,
- Username: username,
- URL: url,
- Passphrase: passphrase,
- Strength: calcStrength(passphrase),
- CreatedAt: time.Now().Format("Jan 2, 2006"),
- }
- s.Keys = append(s.Keys, k)
- return k
-}
-
-func (s *KeyStore) Delete(id string) {
- for i, k := range s.Keys {
- if k.ID == id { s.Keys = append(s.Keys[:i], s.Keys[i+1:]...); return }
- }
-}
-
-func calcStrength(p string) string {
- l, d, u, s := 0, 0, 0, 0
- for _, c := range p {
- switch {
- case c >= 'a' && c <= 'z': l++
- case c >= 'A' && c <= 'Z': u++
- case c >= '0' && c <= '9': d++
- default: s++
- }
- }
- if len(p) >= 12 && u >= 1 && d >= 1 && s >= 1 { return "secure" }
- if len(p) >= 8 && u+d+s >= 2 { return "moderate" }
- return "weak"
-}
-
-func defaultKeys() []Key {
- return []Key{
- {ID: "k1", Name: "AWS Prod Root", Username: "ec2-user", URL: "aws-console.amazon.com", Passphrase: "••••••••••", Strength: "secure", CreatedAt: "Mar 15, 2026"},
- {ID: "k2", Name: "GitHub Deploy", Username: "git", URL: "github.com", Passphrase: "••••••••", Strength: "secure", CreatedAt: "Apr 2, 2026"},
- {ID: "k3", Name: "Dev DB Access", Username: "admin", URL: "dev-db.internal:5432", Passphrase: "••••••", Strength: "moderate", CreatedAt: "May 10, 2026"},
- {ID: "k4", Name: "Old Server", Username: "root", URL: "192.168.1.100", Passphrase: "••••", Strength: "weak", CreatedAt: "Jan 5, 2026"},
- {ID: "k5", Name: "Staging API Key", Username: "api-user", URL: "staging.api.company.com", Passphrase: "•••••••••••", Strength: "secure", CreatedAt: "Jun 1, 2026"},
- }
-}
diff --git a/app/backend/internal/model/snippet.go b/app/backend/internal/model/snippet.go
index 1802f63..2b0e106 100644
--- a/app/backend/internal/model/snippet.go
+++ b/app/backend/internal/model/snippet.go
@@ -1,7 +1,5 @@
package model
-import "time"
-
type Snippet struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -10,52 +8,3 @@ type Snippet struct {
Tags []string `json:"tags"`
CreatedAt string `json:"createdAt"`
}
-
-type SnippetStore struct {
- Snippets []Snippet
-}
-
-func NewSnippetStore() *SnippetStore {
- return &SnippetStore{Snippets: defaultSnippets()}
-}
-
-func (s *SnippetStore) All() []Snippet { return s.Snippets }
-func (s *SnippetStore) Search(q string) []Snippet {
- if q == "" { return s.Snippets }
- var res []Snippet
- for _, sn := range s.Snippets {
- if contains(sn.Name, q) || contains(sn.Content, q) || contains(sn.Language, q) {
- res = append(res, sn)
- }
- }
- return res
-}
-
-func (s *SnippetStore) Add(name, content, language string, tags []string) Snippet {
- sn := Snippet{
- ID: randString(8),
- Name: name,
- Content: content,
- Language: language,
- Tags: tags,
- CreatedAt: time.Now().Format("Jan 2, 2006"),
- }
- s.Snippets = append(s.Snippets, sn)
- return sn
-}
-
-func (s *SnippetStore) Delete(id string) {
- for i, sn := range s.Snippets {
- if sn.ID == id { s.Snippets = append(s.Snippets[:i], s.Snippets[i+1:]...); return }
- }
-}
-
-func defaultSnippets() []Snippet {
- return []Snippet{
- {ID: "s1", Name: "Nginx Reverse Proxy", Content: "server {\n listen 80;\n server_name example.com;\n location / {\n proxy_pass http://localhost:3000;\n proxy_set_header Host $host;\n }\n}", Language: "nginx", Tags: []string{"nginx", "proxy", "web"}, CreatedAt: "Jun 28, 2026"},
- {ID: "s2", Name: "Docker Compose Postgres", Content: "version: '3.8'\nservices:\n db:\n image: postgres:16\n environment:\n POSTGRES_DB: app\n POSTGRES_PASSWORD: ${DB_PASS}\n ports:\n - 5432:5432", Language: "yaml", Tags: []string{"docker", "postgres", "db"}, CreatedAt: "Jun 25, 2026"},
- {ID: "s3", Name: "Health Check Script", Content: "#!/bin/bash\ncurl -sf http://localhost/health || exit 1\necho \"OK\"", Language: "bash", Tags: []string{"monitoring", "bash"}, CreatedAt: "Jun 20, 2026"},
- {ID: "s4", Name: "System Info", Content: "uname -a\ncat /etc/os-release\nfree -h\ndf -h\nuptime", Language: "bash", Tags: []string{"sysadmin", "diagnostics"}, CreatedAt: "Jun 15, 2026"},
- {ID: "s5", Name: "ufw Rules", Content: "ufw default deny incoming\nufw default allow outgoing\nufw allow 22/tcp\nufw allow 443/tcp\nufw enable", Language: "bash", Tags: []string{"security", "firewall"}, CreatedAt: "Jun 10, 2026"},
- }
-}
diff --git a/app/backend/internal/sshconn/client.go b/app/backend/internal/sshconn/client.go
new file mode 100644
index 0000000..e8f5ba4
--- /dev/null
+++ b/app/backend/internal/sshconn/client.go
@@ -0,0 +1,138 @@
+package sshconn
+
+import (
+ "fmt"
+ "io"
+ "net"
+ "os"
+ "time"
+
+ "git.tukangketik.id/swanadiva/hostkeeper/v2/internal/model"
+ gossh "golang.org/x/crypto/ssh"
+)
+
+type Client struct {
+ Host model.Host
+ client *gossh.Client
+ session *gossh.Session
+ stdin io.WriteCloser
+ stdout io.Reader
+ stderr io.Reader
+}
+
+func Dial(host model.Host) (*Client, error) {
+ addr := net.JoinHostPort(host.Hostname, fmt.Sprintf("%d", host.Port))
+ if host.Hostname == "" {
+ addr = net.JoinHostPort(host.IP, "22")
+ }
+
+ config := &gossh.ClientConfig{
+ User: host.Username,
+ HostKeyCallback: gossh.InsecureIgnoreHostKey(),
+ Timeout: 10 * time.Second,
+ }
+
+ switch host.AuthType {
+ case "password", "":
+ config.Auth = []gossh.AuthMethod{
+ gossh.Password(host.Password),
+ }
+ case "key":
+ key, err := loadPrivateKey(host.Password)
+ if err != nil {
+ return nil, fmt.Errorf("load key: %w", err)
+ }
+ config.Auth = []gossh.AuthMethod{
+ gossh.PublicKeys(key),
+ }
+ case "both":
+ key, err := loadPrivateKey(host.Password)
+ if err != nil {
+ return nil, fmt.Errorf("load key: %w", err)
+ }
+ config.Auth = []gossh.AuthMethod{
+ gossh.Password(host.Password),
+ gossh.PublicKeys(key),
+ }
+ default:
+ return nil, fmt.Errorf("unknown auth type: %s", host.AuthType)
+ }
+
+ client, err := gossh.Dial("tcp", addr, config)
+ if err != nil {
+ return nil, fmt.Errorf("dial: %w", err)
+ }
+
+ return &Client{
+ Host: host,
+ client: client,
+ }, nil
+}
+
+func (c *Client) Shell(stdin io.Reader, stdout io.Writer, stderr io.Writer, rows, cols int) error {
+ session, err := c.client.NewSession()
+ if err != nil {
+ return fmt.Errorf("session: %w", err)
+ }
+ c.session = session
+
+ modes := gossh.TerminalModes{
+ gossh.ECHO: 1,
+ gossh.TTY_OP_ISPEED: 14400,
+ gossh.TTY_OP_OSPEED: 14400,
+ }
+
+ if err := session.RequestPty("xterm-256color", rows, cols, modes); err != nil {
+ return fmt.Errorf("pty: %w", err)
+ }
+
+ c.stdin, _ = session.StdinPipe()
+ c.stdout, _ = session.StdoutPipe()
+ c.stderr, _ = session.StderrPipe()
+
+ go io.Copy(stdout, c.stdout)
+ go io.Copy(stderr, c.stderr)
+ go io.Copy(c.stdin, stdin)
+
+ if err := session.Shell(); err != nil {
+ return fmt.Errorf("shell: %w", err)
+ }
+
+ return session.Wait()
+}
+
+func (c *Client) Resize(rows, cols int) error {
+ if c.session == nil {
+ return nil
+ }
+ return c.session.WindowChange(rows, cols)
+}
+
+func (c *Client) Close() error {
+ if c.session != nil {
+ c.session.Close()
+ }
+ return c.client.Close()
+}
+
+func (c *Client) IsConnected() bool {
+ return c.client != nil
+}
+
+func loadPrivateKey(keyContent string) (gossh.Signer, error) {
+ if keyContent == "" {
+ paths := []string{
+ os.ExpandEnv("$HOME/.ssh/id_ed25519"),
+ os.ExpandEnv("$HOME/.ssh/id_rsa"),
+ os.ExpandEnv("$HOME/.ssh/id_ecdsa"),
+ }
+ for _, p := range paths {
+ data, err := os.ReadFile(p)
+ if err == nil {
+ return gossh.ParsePrivateKey(data)
+ }
+ }
+ return nil, fmt.Errorf("no SSH key found")
+ }
+ return gossh.ParsePrivateKey([]byte(keyContent))
+}
diff --git a/app/backend/internal/store/devices.go b/app/backend/internal/store/devices.go
new file mode 100644
index 0000000..3f7ab14
--- /dev/null
+++ b/app/backend/internal/store/devices.go
@@ -0,0 +1,32 @@
+package store
+
+import "git.tukangketik.id/swanadiva/hostkeeper/v2/internal/model"
+
+type DeviceStore struct {
+ devices []model.Device
+}
+
+func NewDeviceStore() *DeviceStore {
+ s := &DeviceStore{devices: defaultDevices()}
+ mu.RLock()
+ readFile("devices.json", &s.devices)
+ mu.RUnlock()
+ if s.devices == nil {
+ s.devices = defaultDevices()
+ }
+ return s
+}
+
+func (s *DeviceStore) All() []model.Device {
+ mu.RLock()
+ defer mu.RUnlock()
+ return s.devices
+}
+
+func defaultDevices() []model.Device {
+ return []model.Device{
+ {ID: "d1", Name: "MacBook Pro M4", Type: "desktop", OS: "macOS 15 Sequoia", LastSeen: "Just now", Current: true},
+ {ID: "d2", Name: "iPhone 17 Pro", Type: "mobile", OS: "iOS 20", LastSeen: "2 hours ago", Current: false},
+ {ID: "d3", Name: "iPad Air M3", Type: "tablet", OS: "iPadOS 20", LastSeen: "Yesterday", Current: false},
+ }
+}
diff --git a/app/backend/internal/store/host.go b/app/backend/internal/store/host.go
new file mode 100644
index 0000000..f6ec80d
--- /dev/null
+++ b/app/backend/internal/store/host.go
@@ -0,0 +1,129 @@
+package store
+
+import "git.tukangketik.id/swanadiva/hostkeeper/v2/internal/model"
+
+type HostStore struct {
+ hosts []model.Host
+}
+
+func NewHostStore() *HostStore {
+ s := &HostStore{}
+ mu.RLock()
+ readFile("hosts.json", &s.hosts)
+ mu.RUnlock()
+ if s.hosts == nil {
+ s.hosts = []model.Host{}
+ }
+ return s
+}
+
+func (s *HostStore) All() []model.Host {
+ mu.RLock()
+ defer mu.RUnlock()
+ return s.hosts
+}
+
+func (s *HostStore) Search(q, filter string) []model.Host {
+ mu.RLock()
+ defer mu.RUnlock()
+ var result []model.Host
+ for _, h := range s.hosts {
+ if filter != "" && filter != "all" && h.Status != filter {
+ continue
+ }
+ if q == "" || contains(h.Name, q) || contains(h.IP, q) || contains(h.OS, q) ||
+ contains(h.Hostname, q) || contains(h.Username, q) {
+ result = append(result, h)
+ }
+ }
+ if result == nil {
+ return []model.Host{}
+ }
+ return result
+}
+
+func (s *HostStore) Get(id string) (model.Host, bool) {
+ mu.RLock()
+ defer mu.RUnlock()
+ for _, h := range s.hosts {
+ if h.ID == id {
+ return h, true
+ }
+ }
+ return model.Host{}, false
+}
+
+func (s *HostStore) Add(name, ip, os, provider, hostType string) model.Host {
+ return s.AddFull(model.Host{
+ Name: name,
+ IP: ip,
+ OS: os,
+ Provider: provider,
+ Type: hostType,
+ })
+}
+
+func (s *HostStore) AddFull(h model.Host) model.Host {
+ mu.Lock()
+ defer mu.Unlock()
+ if h.ID == "" {
+ h.ID = randID()
+ }
+ if h.Status == "" {
+ h.Status = "active"
+ }
+ s.hosts = append(s.hosts, h)
+ writeFile("hosts.json", &s.hosts)
+ return h
+}
+
+func (s *HostStore) Update(h model.Host) bool {
+ mu.Lock()
+ defer mu.Unlock()
+ for i, existing := range s.hosts {
+ if existing.ID == h.ID {
+ s.hosts[i] = h
+ writeFile("hosts.json", &s.hosts)
+ return true
+ }
+ }
+ return false
+}
+
+func (s *HostStore) Delete(id string) {
+ mu.Lock()
+ defer mu.Unlock()
+ for i, h := range s.hosts {
+ if h.ID == id {
+ s.hosts = append(s.hosts[:i], s.hosts[i+1:]...)
+ writeFile("hosts.json", &s.hosts)
+ return
+ }
+ }
+}
+
+func contains(s, q string) bool {
+ if len(s) < len(q) {
+ return false
+ }
+ for i := 0; i <= len(s)-len(q); i++ {
+ match := true
+ for j := 0; j < len(q); j++ {
+ sc, qc := s[i+j], q[j]
+ if sc >= 'A' && sc <= 'Z' {
+ sc += 32
+ }
+ if qc >= 'A' && qc <= 'Z' {
+ qc += 32
+ }
+ if sc != qc {
+ match = false
+ break
+ }
+ }
+ if match {
+ return true
+ }
+ }
+ return false
+}
diff --git a/app/backend/internal/store/key.go b/app/backend/internal/store/key.go
new file mode 100644
index 0000000..3dc1d69
--- /dev/null
+++ b/app/backend/internal/store/key.go
@@ -0,0 +1,94 @@
+package store
+
+import (
+ "time"
+ "git.tukangketik.id/swanadiva/hostkeeper/v2/internal/model"
+)
+
+type KeyStore struct {
+ keys []model.Key
+}
+
+func NewKeyStore() *KeyStore {
+ s := &KeyStore{}
+ mu.RLock()
+ readFile("keys.json", &s.keys)
+ mu.RUnlock()
+ if s.keys == nil {
+ s.keys = []model.Key{}
+ }
+ return s
+}
+
+func (s *KeyStore) All() []model.Key {
+ mu.RLock()
+ defer mu.RUnlock()
+ return s.keys
+}
+
+func (s *KeyStore) Search(q string) []model.Key {
+ mu.RLock()
+ defer mu.RUnlock()
+ if q == "" {
+ return s.keys
+ }
+ var res []model.Key
+ for _, k := range s.keys {
+ if contains(k.Name, q) || contains(k.Username, q) || contains(k.URL, q) {
+ res = append(res, k)
+ }
+ }
+ return res
+}
+
+func (s *KeyStore) Add(name, username, url, passphrase string) model.Key {
+ mu.Lock()
+ defer mu.Unlock()
+ k := model.Key{
+ ID: randID(),
+ Name: name,
+ Username: username,
+ URL: url,
+ Passphrase: passphrase,
+ Strength: calcStrength(passphrase),
+ CreatedAt: time.Now().Format("Jan 2, 2006"),
+ }
+ s.keys = append(s.keys, k)
+ writeFile("keys.json", &s.keys)
+ return k
+}
+
+func (s *KeyStore) Delete(id string) {
+ mu.Lock()
+ defer mu.Unlock()
+ for i, k := range s.keys {
+ if k.ID == id {
+ s.keys = append(s.keys[:i], s.keys[i+1:]...)
+ writeFile("keys.json", &s.keys)
+ return
+ }
+ }
+}
+
+func calcStrength(p string) string {
+ l, d, u, s := 0, 0, 0, 0
+ for _, c := range p {
+ switch {
+ case c >= 'a' && c <= 'z':
+ l++
+ case c >= 'A' && c <= 'Z':
+ u++
+ case c >= '0' && c <= '9':
+ d++
+ default:
+ s++
+ }
+ }
+ if len(p) >= 12 && u >= 1 && d >= 1 && s >= 1 {
+ return "secure"
+ }
+ if len(p) >= 8 && u+d+s >= 2 {
+ return "moderate"
+ }
+ return "weak"
+}
diff --git a/app/backend/internal/store/settings.go b/app/backend/internal/store/settings.go
new file mode 100644
index 0000000..1da6666
--- /dev/null
+++ b/app/backend/internal/store/settings.go
@@ -0,0 +1,42 @@
+package store
+
+import "git.tukangketik.id/swanadiva/hostkeeper/v2/internal/model"
+
+type SettingsStore struct {
+ cfg model.AppConfig
+}
+
+func NewSettingsStore() *SettingsStore {
+ s := &SettingsStore{cfg: defaultConfig()}
+ mu.RLock()
+ readFile("settings.json", &s.cfg)
+ mu.RUnlock()
+ return s
+}
+
+func (s *SettingsStore) Get() model.AppConfig {
+ mu.RLock()
+ defer mu.RUnlock()
+ return s.cfg
+}
+
+func (s *SettingsStore) Update(cfg model.AppConfig) {
+ mu.Lock()
+ defer mu.Unlock()
+ s.cfg = cfg
+ writeFile("settings.json", &s.cfg)
+}
+
+func defaultConfig() model.AppConfig {
+ return model.AppConfig{
+ Theme: "light",
+ FontSize: 14,
+ Scrollback: 5000,
+ AutoLock: 5,
+ Keepalive: true,
+ CopyOnSelect: true,
+ BellEnabled: false,
+ AutoReconnect: true,
+ BlinkCursor: false,
+ }
+}
diff --git a/app/backend/internal/store/snippet.go b/app/backend/internal/store/snippet.go
new file mode 100644
index 0000000..5ba0e87
--- /dev/null
+++ b/app/backend/internal/store/snippet.go
@@ -0,0 +1,70 @@
+package store
+
+import (
+ "time"
+ "git.tukangketik.id/swanadiva/hostkeeper/v2/internal/model"
+)
+
+type SnippetStore struct {
+ snippets []model.Snippet
+}
+
+func NewSnippetStore() *SnippetStore {
+ s := &SnippetStore{}
+ mu.RLock()
+ readFile("snippets.json", &s.snippets)
+ mu.RUnlock()
+ if s.snippets == nil {
+ s.snippets = []model.Snippet{}
+ }
+ return s
+}
+
+func (s *SnippetStore) All() []model.Snippet {
+ mu.RLock()
+ defer mu.RUnlock()
+ return s.snippets
+}
+
+func (s *SnippetStore) Search(q string) []model.Snippet {
+ mu.RLock()
+ defer mu.RUnlock()
+ if q == "" {
+ return s.snippets
+ }
+ var res []model.Snippet
+ for _, sn := range s.snippets {
+ if contains(sn.Name, q) || contains(sn.Content, q) || contains(sn.Language, q) {
+ res = append(res, sn)
+ }
+ }
+ return res
+}
+
+func (s *SnippetStore) Add(name, content, language string, tags []string) model.Snippet {
+ mu.Lock()
+ defer mu.Unlock()
+ sn := model.Snippet{
+ ID: randID(),
+ Name: name,
+ Content: content,
+ Language: language,
+ Tags: tags,
+ CreatedAt: time.Now().Format("Jan 2, 2006"),
+ }
+ s.snippets = append(s.snippets, sn)
+ writeFile("snippets.json", &s.snippets)
+ return sn
+}
+
+func (s *SnippetStore) Delete(id string) {
+ mu.Lock()
+ defer mu.Unlock()
+ for i, sn := range s.snippets {
+ if sn.ID == id {
+ s.snippets = append(s.snippets[:i], s.snippets[i+1:]...)
+ writeFile("snippets.json", &s.snippets)
+ return
+ }
+ }
+}
diff --git a/app/backend/internal/store/store.go b/app/backend/internal/store/store.go
new file mode 100644
index 0000000..c0c4aa1
--- /dev/null
+++ b/app/backend/internal/store/store.go
@@ -0,0 +1,59 @@
+package store
+
+import (
+ "encoding/json"
+ "math/rand"
+ "os"
+ "path/filepath"
+ "sync"
+
+ "git.tukangketik.id/swanadiva/hostkeeper/pkg/crypto"
+)
+
+var dataDir string
+var mu sync.RWMutex
+
+func init() {
+ configDir, err := os.UserConfigDir()
+ if err != nil {
+ configDir = os.TempDir()
+ }
+ dataDir = filepath.Join(configDir, "hostkeeper", "v2")
+ os.MkdirAll(dataDir, 0755)
+}
+
+func readFile(name string, v any) error {
+ path := filepath.Join(dataDir, name)
+ data, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil
+ }
+ return err
+ }
+ if len(data) == 0 {
+ return nil
+ }
+ if crypto.IsEncrypted(string(data)) {
+ return nil
+ }
+ return json.Unmarshal(data, v)
+}
+
+func writeFile(name string, v any) error {
+ path := filepath.Join(dataDir, name)
+ data, err := json.MarshalIndent(v, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(path, data, 0644)
+}
+
+func randID() string {
+ const letters = "abcdefghijklmnopqrstuvwxyz0123456789"
+ b := make([]byte, 8)
+ for i := range b {
+ b[i] = letters[rand.Intn(len(letters))]
+ }
+ return string(b)
+}
diff --git a/app/backend/static/js/terminal.js b/app/backend/static/js/terminal.js
index 2922862..28f9916 100644
--- a/app/backend/static/js/terminal.js
+++ b/app/backend/static/js/terminal.js
@@ -1,10 +1,8 @@
document.addEventListener('alpine:init', () => {
- const hostOptions = [
- { id: 'h1', name: 'api-prod-01', ip: '10.0.1.15' },
- { id: 'h2', name: 'db-primary-01', ip: '10.0.2.5' },
- { id: 'h6', name: 'monitor-01', ip: '10.0.0.5' },
- { id: 'h7', name: 'worker-pool-01', ip: '10.0.4.50' },
- ];
+ const el = document.getElementById('hosts-data');
+ const hostsData = el ? JSON.parse(el.textContent) : [];
+ const hostOptions = hostsData.length > 0 ? hostsData :
+ [{ id: 'h1', name: 'api-prod-01', ip: '10.0.1.15' }];
Alpine.data('terminalManager', () => ({
tabs: [],
diff --git a/app/backend/views/terminal.html b/app/backend/views/terminal.html
index ba7146a..2b1e6f9 100644
--- a/app/backend/views/terminal.html
+++ b/app/backend/views/terminal.html
@@ -55,6 +55,7 @@
+