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" .}}
+
+
+
+
+
+
+
+ ⚡
+ 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}}
+
+
+
+
+ {{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" .}}
+
+
+
+
+
+
+ {{range .Keys}}
+
+
+
+
+
+
{{.Name}}
+
{{.Username}} @ {{.URL}}
+
+
+
+
+
+ {{.Passphrase}}
+
+ {{.Strength}}
+
+
+
+
+ {{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" .}}
-
-
-
- {{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}}
+
+
+ {{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" .}}
+
+
+
+
+
+
+
+
+
+
+
+ {{range .Snippets}}
+
+
+
+
{{.Name}}
+
{{.Language}}
+
+
+
+
{{.Content}}
+
+
+ {{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)` |
---