diff --git a/AGENTS.md b/AGENTS.md index a792077..6886f7d 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 selesai, Sprint 2 sedang dikerjakan +> **Current Phase**: Sprint 1 + 2 selesai, Sprint 3 berikutnya --- @@ -114,8 +114,8 @@ Alasan: - **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 (IN PROGRESS)**: Snippets + Keychain -- Sprint 3: Settings + Brief +- **Sprint 2 (DONE)**: Snippets + Keychain +- **Sprint 3 (NEXT)**: Settings + Brief - Sprint 4: SFTP Dual-Pane - Sprint 5: Terminal + xterm.js - Sprint 6: Storage Integration diff --git a/app/backend/internal/handler/dashboard.go b/app/backend/internal/handler/dashboard.go index fad7796..d219897 100644 --- a/app/backend/internal/handler/dashboard.go +++ b/app/backend/internal/handler/dashboard.go @@ -7,6 +7,15 @@ import ( var store = model.NewHostStore() +var navItems = []fiber.Map{ + {"ID": "hosts", "Label": "Hosts", "Icon": "server"}, + {"ID": "terminal", "Label": "Terminal", "Icon": "terminal"}, + {"ID": "sftp", "Label": "SFTP", "Icon": "folder-sync"}, + {"ID": "snippets", "Label": "Snippets", "Icon": "code2"}, + {"ID": "keychain", "Label": "Keychain", "Icon": "key-round"}, + {"ID": "settings", "Label": "Settings", "Icon": "settings2"}, +} + func Dashboard(c *fiber.Ctx) error { hosts := store.All() return c.Render("index", fiber.Map{ @@ -15,14 +24,7 @@ func Dashboard(c *fiber.Ctx) error { "Hosts": hosts, "Active": countByStatus(hosts, "active"), "Offline": countByStatus(hosts, "offline"), - "NavItems": []fiber.Map{ - {"ID": "hosts", "Label": "Hosts", "Icon": "server"}, - {"ID": "terminal", "Label": "Terminal", "Icon": "terminal"}, - {"ID": "sftp", "Label": "SFTP", "Icon": "folder-sync"}, - {"ID": "snippets", "Label": "Snippets", "Icon": "code2"}, - {"ID": "keychain", "Label": "Keychain", "Icon": "key-round"}, - {"ID": "settings", "Label": "Settings", "Icon": "settings2"}, - }, + "NavItems": navItems, }) } diff --git a/app/backend/internal/handler/keychain.go b/app/backend/internal/handler/keychain.go new file mode 100644 index 0000000..a8f7666 --- /dev/null +++ b/app/backend/internal/handler/keychain.go @@ -0,0 +1,41 @@ +package handler + +import ( + "github.com/gofiber/fiber/v2" + "git.tukangketik.id/swanadiva/hostkeeper/v2/internal/model" +) + +var keyStore = model.NewKeyStore() + +func Keychain(c *fiber.Ctx) error { + keys := keyStore.All() + return c.Render("keychain", fiber.Map{ + "View": "keychain", + "Title": "Keychain", + "Keys": keys, + "NavItems": navItems, + }) +} + +func KeyGrid(c *fiber.Ctx) error { + q := c.Query("q", "") + keys := keyStore.Search(q) + return c.Render("credential_grid", fiber.Map{"Keys": keys}) +} + +func CreateKey(c *fiber.Ctx) error { + name := c.FormValue("name") + username := c.FormValue("username") + url := c.FormValue("url") + passphrase := c.FormValue("passphrase") + keyStore.Add(name, username, url, passphrase) + keys := keyStore.All() + return c.Render("credential_grid", fiber.Map{"Keys": keys}) +} + +func DeleteKey(c *fiber.Ctx) error { + id := c.Params("id") + keyStore.Delete(id) + keys := keyStore.All() + return c.Render("credential_grid", fiber.Map{"Keys": keys}) +} diff --git a/app/backend/internal/handler/snippets.go b/app/backend/internal/handler/snippets.go new file mode 100644 index 0000000..79e008b --- /dev/null +++ b/app/backend/internal/handler/snippets.go @@ -0,0 +1,40 @@ +package handler + +import ( + "github.com/gofiber/fiber/v2" + "git.tukangketik.id/swanadiva/hostkeeper/v2/internal/model" +) + +var snippetStore = model.NewSnippetStore() + +func Snippets(c *fiber.Ctx) error { + snippets := snippetStore.All() + return c.Render("snippets", fiber.Map{ + "View": "snippets", + "Title": "Snippets", + "Snippets": snippets, + "NavItems": navItems, + }) +} + +func SnippetGrid(c *fiber.Ctx) error { + q := c.Query("q", "") + snippets := snippetStore.Search(q) + return c.Render("snippet_grid", fiber.Map{"Snippets": snippets}) +} + +func CreateSnippet(c *fiber.Ctx) error { + name := c.FormValue("name") + content := c.FormValue("content") + language := c.FormValue("language") + snippetStore.Add(name, content, language, nil) + snippets := snippetStore.All() + return c.Render("snippet_grid", fiber.Map{"Snippets": snippets}) +} + +func DeleteSnippet(c *fiber.Ctx) error { + id := c.Params("id") + snippetStore.Delete(id) + snippets := snippetStore.All() + return c.Render("snippet_grid", fiber.Map{"Snippets": snippets}) +} diff --git a/app/backend/internal/model/host.go b/app/backend/internal/model/host.go index f024c30..c75bc21 100644 --- a/app/backend/internal/model/host.go +++ b/app/backend/internal/model/host.go @@ -73,6 +73,10 @@ func (s *HostStore) Add(name, ip, os, provider, hostType string) Host { 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) diff --git a/app/backend/internal/model/keychain.go b/app/backend/internal/model/keychain.go new file mode 100644 index 0000000..f7ef729 --- /dev/null +++ b/app/backend/internal/model/keychain.go @@ -0,0 +1,78 @@ +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 + 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 new file mode 100644 index 0000000..1802f63 --- /dev/null +++ b/app/backend/internal/model/snippet.go @@ -0,0 +1,61 @@ +package model + +import "time" + +type Snippet struct { + ID string `json:"id"` + Name string `json:"name"` + Content string `json:"content"` + Language string `json:"language"` + 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/main.go b/app/backend/main.go index 2da3357..f1b6a63 100644 --- a/app/backend/main.go +++ b/app/backend/main.go @@ -32,6 +32,14 @@ func main() { app.Get("/hosts", handler.DashboardList) app.Post("/hosts", handler.CreateHost) app.Delete("/hosts/:id", handler.DeleteHost) + app.Get("/snippets", handler.Snippets) + app.Get("/snippets/grid", handler.SnippetGrid) + app.Post("/snippets", handler.CreateSnippet) + app.Delete("/snippets/:id", handler.DeleteSnippet) + app.Get("/keychain", handler.Keychain) + app.Get("/keys/grid", handler.KeyGrid) + app.Post("/keys", handler.CreateKey) + app.Delete("/keys/:id", handler.DeleteKey) app.Get("/api/health", handler.Health) log.Fatal(app.Listen(":1947")) diff --git a/app/backend/static/js/clipboard.js b/app/backend/static/js/clipboard.js new file mode 100644 index 0000000..82122d3 --- /dev/null +++ b/app/backend/static/js/clipboard.js @@ -0,0 +1,18 @@ +function copyToClipboard(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).catch(() => fallbackCopy(text)); + } else { + fallbackCopy(text); + } +} + +function fallbackCopy(text) { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + try { document.execCommand('copy'); } catch (e) {} + document.body.removeChild(ta); +} diff --git a/app/backend/static/js/utils.js b/app/backend/static/js/utils.js new file mode 100644 index 0000000..438964d --- /dev/null +++ b/app/backend/static/js/utils.js @@ -0,0 +1,6 @@ +document.addEventListener('alpine:init', () => { + Alpine.data('passphraseToggle', () => ({ + show: false, + toggle() { this.show = !this.show; } + })); +}); diff --git a/app/backend/views/index.html b/app/backend/views/index.html index 4a1b27a..f6783f5 100644 --- a/app/backend/views/index.html +++ b/app/backend/views/index.html @@ -1,63 +1,84 @@ -{{template "layout" .}} + + + + + + Hosts · Hostkeeper V2 + + + + + + + + + + +
+ {{template "nav" .}} +
+
+

{{.Title}}

+
+
+
+
+
+
+ + Active Connections +
+ {{.Active}} +
+
+
+ + Transfers Today +
+ 0 +
+
+
+ # + Saved Hosts +
+ {{len .Hosts}} +
+
-{{define "content"}} -
-
-
-
- - Active Connections -
- {{.Active}} -
-
-
- - Transfers Today -
- 0 -
-
-
- # - Saved Hosts -
- {{len .Hosts}} +
+
+ +
+ +
+ + +
+
+ + {{template "host_list" .}} +
+ {{template "host_modal" .}} +
- -
-
- -
- -
- - -
-
- - {{template "host_list" .}} - -{{template "host_modal" .}} -{{end}} + + diff --git a/app/backend/views/key_grid.html b/app/backend/views/key_grid.html new file mode 100644 index 0000000..5e4567a --- /dev/null +++ b/app/backend/views/key_grid.html @@ -0,0 +1,44 @@ +{{define "credential_grid"}} +
+ {{range .Keys}} +
+
+
+
+ +
+
+

{{.Name}}

+

{{.Username}} @ {{.URL}}

+
+
+ +
+
+ {{.Passphrase}} + + {{.Strength}} + +
+
+ {{.CreatedAt}} + +
+
+ {{end}} + + {{if eq (len .Keys) 0}} +
+

No keys found

+

Add your first credential

+
+ {{end}} +
+{{end}} diff --git a/app/backend/views/keychain.html b/app/backend/views/keychain.html new file mode 100644 index 0000000..a8638ed --- /dev/null +++ b/app/backend/views/keychain.html @@ -0,0 +1,132 @@ + + + + + + Keychain · Hostkeeper V2 + + + + + + + + + + +
+ {{template "nav" .}} +
+
+

{{.Title}}

+
+
+
+
+
+ +
+ +
+
+ {{range .Keys}} +
+
+
+
+ +
+
+

{{.Name}}

+

{{.Username}} @ {{.URL}}

+
+
+ +
+
+ {{.Passphrase}} + + {{.Strength}} + +
+
+ {{.CreatedAt}} + +
+
+ {{end}} + {{if eq (len .Keys) 0}} +
+

No keys found

+

Add your first credential

+
+ {{end}} +
+
+
+
+
+
+
+

Add New Credential

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+
+
+
+
+
+
+ + diff --git a/app/backend/views/layout.html b/app/backend/views/layout.html deleted file mode 100644 index a532019..0000000 --- a/app/backend/views/layout.html +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - Hostkeeper V2 - - - - - - - - -
- {{template "nav" .}} -
-
-

{{.Title}}

-
-
- {{block "content" .}}{{end}} -
-
-
- - diff --git a/app/backend/views/snippet_grid.html b/app/backend/views/snippet_grid.html new file mode 100644 index 0000000..2a499be --- /dev/null +++ b/app/backend/views/snippet_grid.html @@ -0,0 +1,33 @@ +{{define "snippet_grid"}} +
+ {{range .Snippets}} +
+
+
+

{{.Name}}

+ {{.Language}} +
+ +
+
{{.Content}}
+
+ {{.CreatedAt}} + +
+
+ {{end}} + + {{if eq (len .Snippets) 0}} +
+

No snippets found

+

Add your first snippet to get started

+
+ {{end}} +
+{{end}} diff --git a/app/backend/views/snippets.html b/app/backend/views/snippets.html new file mode 100644 index 0000000..a5fc06f --- /dev/null +++ b/app/backend/views/snippets.html @@ -0,0 +1,118 @@ + + + + + + Snippets · Hostkeeper V2 + + + + + + + + + + +
+ {{template "nav" .}} +
+
+

{{.Title}}

+
+
+
+
+
+ +
+ +
+
+ {{range .Snippets}} +
+
+
+

{{.Name}}

+ {{.Language}} +
+ +
+
{{.Content}}
+
+ {{.CreatedAt}} + +
+
+ {{end}} + {{if eq (len .Snippets) 0}} +
+

No snippets found

+

Add your first snippet to get started

+
+ {{end}} +
+
+
+
+
+
+
+

Add New Snippet

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+
+
+ + diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index c9e1ebf..2c5a323 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -36,7 +36,7 @@ Lihat [ARCHITECTURE_HTMX.md](ARCHITECTURE_HTMX.md) untuk detail. | Planning documents (HTMX) | DONE | | Sprint 0 — Setup GoFiber + HTMX | DONE | | Sprint 1 — Dashboard + Host List | DONE | -| Sprint 2 — Snippets + Keychain | IN PROGRESS | +| Sprint 2 — Snippets + Keychain | DONE | | Sprint 3 — Settings + Brief | NOT STARTED | | Sprint 4 — SFTP Dual-Pane | NOT STARTED | | Sprint 5 — Terminal + xterm.js | NOT STARTED | @@ -75,19 +75,18 @@ Lihat [ARCHITECTURE_HTMX.md](ARCHITECTURE_HTMX.md) untuk detail. - **Verify**: Homepage renders with 8 hosts, search+filter works, create+delete CRUD ### Sprint 2 — Snippets + Keychain -- [ ] Model Snippet + SnippetStore (`internal/model/snippet.go`) -- [ ] Model Key + KeyStore (`internal/model/keychain.go`) -- [ ] Snippets handler (`internal/handler/snippets.go`) -- [ ] Keychain handler (`internal/handler/keychain.go`) -- [ ] Snippets template (`views/snippets.html`) -- [ ] Snippet grid partial (`views/snippet_grid.html`) -- [ ] Snippet modal (`views/snippet_modal.html`) -- [ ] Keychain template (`views/keychain.html`) -- [ ] Key grid partial (`views/key_grid.html`) -- [ ] Key modal (`views/key_modal.html`) -- [ ] Clipboard JS (`static/js/clipboard.js`) -- [ ] Passphrase reveal + strength (`static/js/utils.js`) -- **Verify**: Both pages render, CRUD works, clipboard copies, passphrase reveal toggles +- [x] Model Snippet + SnippetStore (`internal/model/snippet.go`) +- [x] Model Key + KeyStore (`internal/model/keychain.go`) +- [x] Snippets handler (`internal/handler/snippets.go`) +- [x] Keychain handler (`internal/handler/keychain.go`) +- [x] Snippets template (`views/snippets.html`) — standalone page (no layout inheritance) +- [x] Snippet grid partial (`views/snippet_grid.html`) — HTMX partial +- [x] Keychain template (`views/keychain.html`) — standalone page +- [x] Key grid partial (`views/key_grid.html`) — HTMX partial +- [x] Clipboard JS (`static/js/clipboard.js`) +- [x] Utils JS (`static/js/utils.js`) +- **Note**: Removed `layout.html` — all pages are now standalone (Go template `{{define}}` conflict) +- **Verify**: Both pages render, CRUD works, clipboard copies, strength badges display ### Sprint 3 — Settings + Brief - [ ] Model Device + Config @@ -143,6 +142,7 @@ Lihat [ARCHITECTURE_HTMX.md](ARCHITECTURE_HTMX.md) untuk detail. | 2026-07-07 | Sprint 0: GoFiber+HTMX scaffolding, TailwindCSS, layout, nav, health | `43a19b7` | | 2026-07-07 | Sprint 1: Host model+store, dashboard CRUD, HTMX partials (grid/list, search, filter, modal, delete), transfers panel | `d771925`, `86620b0` | | 2026-07-07 | Sprint 2 dimulai: Snippet + Keychain models created | — | +| 2026-07-07 | Sprint 2 selesai: all CRUD, standalone templates, clipboard.js, utils.js, fix template {{define}} conflict | `(pending)` | ---