diff --git a/TASKS/reports/T12-report.md b/TASKS/reports/T12-report.md new file mode 100644 index 0000000..31ed76e --- /dev/null +++ b/TASKS/reports/T12-report.md @@ -0,0 +1,68 @@ +# T12 Izveštaj: Dashboard — prikaz dokumentacije i CLAUDE.md + +**Agent:** coder +**Model:** Opus +**Datum:** 2026-02-20 + +--- + +## Šta je urađeno + +Dodat docs viewer na dashboard sa goldmark markdown renderovanjem. + +### Novi fajlovi + +| Fajl | Opis | +|------|------| +| `internal/server/docs.go` | Docs handleri, goldmark renderovanje, path traversal zaštita, link rewriting | +| `web/templates/docs-list.html` | Template za listu .md fajlova | +| `web/templates/docs-view.html` | Template za prikaz renderovanog markdowna sa breadcrumbs | + +### Izmenjeni fajlovi + +| Fajl | Izmena | +|------|--------| +| `internal/server/server.go` | GET /docs i GET /docs/*path rute | +| `internal/server/render.go` | renderDocsList(), renderDocsView(), novi templates u init() | +| `internal/server/server_test.go` | 9 novih testova, .md fajlovi u test setup | +| `web/static/style.css` | Nav, docs-container, docs-list, docs-content, breadcrumbs stilovi | +| `web/templates/layout.html` | Nav bar sa Kanban/Dokumenti linkovima | +| `go.mod` / `go.sum` | goldmark v1.7.16 | + +### Endpointi + +| Ruta | Opis | +|------|------| +| `GET /docs` | Lista svih .md fajlova u projektu | +| `GET /docs/{path}` | Renderovan markdown fajl sa breadcrumbs | + +### Bezbednost + +- Samo .md fajlovi (ostalo → 403) +- Path traversal zaštita (`../../etc/passwd` → 403) +- filepath.Clean + HasPrefix provera + +### Markdown features + +- goldmark sa Table i Strikethrough ekstenzijama +- Relativni .md linkovi se rewrite-uju u `/docs/` linkove +- Code blokovi, tabele, liste, blockquote — sve renderovano + +### Novi testovi + +``` +TestDocsList PASS +TestDocsView_CLAUDE PASS +TestDocsView_NestedFile PASS +TestDocsView_PathTraversal PASS +TestDocsView_NonMarkdown PASS +TestDocsView_NotFound PASS +TestDocsView_HasBreadcrumbs PASS +TestRewriteLinksSimple PASS +TestRewriteLinksSimple_NestedDir PASS +``` + +### Ukupno projekat: 101 test, svi prolaze + +- `go vet ./...` — čist +- `go build ./...` — prolazi diff --git a/TASKS/review/T12.md b/TASKS/review/T12.md new file mode 100644 index 0000000..1fa2012 --- /dev/null +++ b/TASKS/review/T12.md @@ -0,0 +1,70 @@ +# T12: Dashboard — prikaz dokumentacije i CLAUDE.md + +**Kreirao:** planer +**Datum:** 2026-02-20 +**Agent:** coder +**Model:** Sonnet +**Zavisi od:** T11 + +--- + +## Opis + +Dodaj tab/sekciju na dashboard za prikaz dokumentacije. +Prikazuje CLAUDE.md renderovan kao HTML, sa klikabilnim referencama +koje otvaraju referencirane fajlove inline. + +## Endpointi + +``` +GET /docs → lista svih .md fajlova (HTML) +GET /docs/{path...} → renderovan markdown fajl (HTML fragment) +``` + +## Kako radi + +1. Sidebar ili tab "Dokumenti" pored Kanban boarda +2. Klik → prikaže CLAUDE.md renderovan kao HTML +3. Reference u tabeli (npr. `agents/coder/CLAUDE.md`) su linkovi +4. Klik na link → HTMX učita taj fajl: `hx-get="/docs/agents/coder/CLAUDE.md"` +5. Breadcrumb navigacija: CLAUDE.md > agents > coder > CLAUDE.md +6. Dugme "Nazad" vraća na prethodni fajl + +## Markdown renderovanje + +- Go library: `github.com/gomarkdown/markdown` ili `goldmark` +- Renderuj server-side u HTML +- Tabele, code blokovi, headeri — sve podržano +- Relativne putanje u linkovima → pretvaraju se u /docs/ linkove + +## Fajlovi koji se prikazuju + +Svi .md fajlovi u projektu: +- CLAUDE.md (glavni) +- agents/*/CLAUDE.md +- TASKS/*.md +- TASKS/reports/*.md +- README.md + +## Bezbednost + +- Samo .md fajlovi (ne dozvoli čitanje .go, .env itd.) +- Samo unutar project root-a (ne dozvoli ../../../etc/passwd) +- Path traversal zaštita + +## Testovi + +- GET /docs → lista fajlova +- GET /docs/CLAUDE.md → renderovan HTML +- GET /docs/agents/coder/CLAUDE.md → renderovan HTML +- GET /docs/../../etc/passwd → 403 +- GET /docs/main.go → 403 (nije .md) +- Klikabilni linkovi u renderovanom HTML-u + +--- + +## Pitanja + +--- + +## Odgovori diff --git a/code/go.mod b/code/go.mod index c3d066e..ae7dbf2 100644 --- a/code/go.mod +++ b/code/go.mod @@ -26,6 +26,7 @@ require ( github.com/quic-go/quic-go v0.54.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect + github.com/yuin/goldmark v1.7.16 // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/arch v0.20.0 // indirect golang.org/x/crypto v0.40.0 // indirect diff --git a/code/go.sum b/code/go.sum index a25fff5..06d0305 100644 --- a/code/go.sum +++ b/code/go.sum @@ -61,6 +61,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= +github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= diff --git a/code/internal/server/docs.go b/code/internal/server/docs.go new file mode 100644 index 0000000..bedbd4d --- /dev/null +++ b/code/internal/server/docs.go @@ -0,0 +1,208 @@ +package server + +import ( + "bytes" + htmltpl "html/template" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/gin-gonic/gin" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/renderer/html" +) + +// docFile represents a markdown file in the file listing. +type docFile struct { + Path string // relative path from project root + Name string // display name +} + +// docsListData holds data for the docs listing page. +type docsListData struct { + Files []docFile +} + +// docsViewData holds data for viewing a single doc. +type docsViewData struct { + Path string + Breadcrumbs []breadcrumb + HTML htmltpl.HTML +} + +// breadcrumb represents one segment of the navigation path. +type breadcrumb struct { + Name string + Path string +} + +// md is the goldmark markdown renderer. +var md = goldmark.New( + goldmark.WithExtensions(extension.Table, extension.Strikethrough), + goldmark.WithRendererOptions(html.WithUnsafe()), +) + +// projectRoot returns the KAOS project root derived from TasksDir. +func (s *Server) projectRoot() string { + return filepath.Dir(s.Config.TasksDir) +} + +// handleDocsList serves the docs listing page. +func (s *Server) handleDocsList(c *gin.Context) { + root := s.projectRoot() + files := scanMarkdownFiles(root) + + c.Header("Content-Type", "text/html; charset=utf-8") + c.String(http.StatusOK, renderDocsList(docsListData{Files: files})) +} + +// handleDocsView serves a rendered markdown file. +func (s *Server) handleDocsView(c *gin.Context) { + relPath := c.Param("path") + // Strip leading slash from wildcard param + relPath = strings.TrimPrefix(relPath, "/") + + root := s.projectRoot() + + // Security: only .md files + if !strings.HasSuffix(relPath, ".md") { + c.String(http.StatusForbidden, "Samo .md fajlovi su dozvoljeni") + return + } + + // Security: resolve and check path traversal + absPath := filepath.Join(root, relPath) + absPath = filepath.Clean(absPath) + if !strings.HasPrefix(absPath, filepath.Clean(root)+string(filepath.Separator)) { + c.String(http.StatusForbidden, "Pristup odbijen") + return + } + + content, err := os.ReadFile(absPath) + if err != nil { + c.String(http.StatusNotFound, "Fajl nije pronađen: %s", relPath) + return + } + + // Render markdown to HTML + rendered := renderMarkdown(content, relPath) + + data := docsViewData{ + Path: relPath, + Breadcrumbs: buildBreadcrumbs(relPath), + HTML: htmltpl.HTML(rendered), + } + + c.Header("Content-Type", "text/html; charset=utf-8") + c.String(http.StatusOK, renderDocsView(data)) +} + +// scanMarkdownFiles walks the project root and returns all .md files. +func scanMarkdownFiles(root string) []docFile { + var files []docFile + + filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + + // Skip hidden dirs, code/vendor, .git + name := info.Name() + if info.IsDir() { + if strings.HasPrefix(name, ".") || name == "vendor" || name == "node_modules" { + return filepath.SkipDir + } + return nil + } + + if !strings.HasSuffix(name, ".md") { + return nil + } + + rel, _ := filepath.Rel(root, path) + files = append(files, docFile{ + Path: rel, + Name: rel, + }) + return nil + }) + + return files +} + +// renderMarkdown converts markdown bytes to HTML, rewriting relative .md links to /docs/ links. +func renderMarkdown(source []byte, docPath string) string { + var buf bytes.Buffer + if err := md.Convert(source, &buf); err != nil { + return "

Greška pri renderovanju: " + err.Error() + "

" + } + + html := buf.String() + + // Rewrite relative .md links to /docs/ links + // Find href="something.md" or href="path/to/file.md" patterns + html = rewriteMarkdownLinks(html, docPath) + + return html +} + +// rewriteMarkdownLinks converts relative .md hrefs to /docs/ prefixed paths. +func rewriteMarkdownLinks(htmlContent, currentPath string) string { + dir := filepath.Dir(currentPath) + return rewriteLinksSimple(htmlContent, dir) +} + +// rewriteLinksSimple does a single pass to rewrite .md links. +func rewriteLinksSimple(htmlContent, baseDir string) string { + var result strings.Builder + remaining := htmlContent + + for { + idx := strings.Index(remaining, `href="`) + if idx == -1 { + result.WriteString(remaining) + break + } + + result.WriteString(remaining[:idx+6]) + remaining = remaining[idx+6:] + + endQuote := strings.Index(remaining, `"`) + if endQuote == -1 { + result.WriteString(remaining) + break + } + + href := remaining[:endQuote] + remaining = remaining[endQuote:] + + if strings.HasSuffix(href, ".md") && !strings.HasPrefix(href, "http") && !strings.HasPrefix(href, "/") { + if baseDir == "." || baseDir == "" { + result.WriteString("/docs/" + href) + } else { + result.WriteString("/docs/" + filepath.Join(baseDir, href)) + } + } else { + result.WriteString(href) + } + } + + return result.String() +} + +// buildBreadcrumbs creates navigation breadcrumbs from a file path. +func buildBreadcrumbs(path string) []breadcrumb { + parts := strings.Split(path, "/") + crumbs := make([]breadcrumb, len(parts)) + + for i, part := range parts { + crumbs[i] = breadcrumb{ + Name: part, + Path: strings.Join(parts[:i+1], "/"), + } + } + + return crumbs +} diff --git a/code/internal/server/render.go b/code/internal/server/render.go index 95ff9d5..1c02f10 100644 --- a/code/internal/server/render.go +++ b/code/internal/server/render.go @@ -58,6 +58,8 @@ func init() { web.TemplatesFS, "templates/layout.html", "templates/dashboard.html", + "templates/docs-list.html", + "templates/docs-view.html", "templates/partials/column.html", "templates/partials/task-card.html", "templates/partials/task-detail.html", @@ -86,6 +88,24 @@ func renderDashboard(columns map[string][]supervisor.Task) string { return buf.String() } +// renderDocsList generates the docs listing HTML page. +func renderDocsList(data docsListData) string { + var buf bytes.Buffer + if err := templates.ExecuteTemplate(&buf, "docs-list", data); err != nil { + return "Greška pri renderovanju: " + err.Error() + } + return buf.String() +} + +// renderDocsView generates the docs view HTML page. +func renderDocsView(data docsViewData) string { + var buf bytes.Buffer + if err := templates.ExecuteTemplate(&buf, "docs-view", data); err != nil { + return "Greška pri renderovanju: " + err.Error() + } + return buf.String() +} + // renderTaskDetail generates HTML fragment for task detail panel. func renderTaskDetail(t supervisor.Task, content string, hasReport bool) string { data := taskDetailData{ diff --git a/code/internal/server/server.go b/code/internal/server/server.go index f3f36ce..9c15d73 100644 --- a/code/internal/server/server.go +++ b/code/internal/server/server.go @@ -96,6 +96,10 @@ func (s *Server) setupRoutes() { s.Router.GET("/task/:id", s.handleTaskDetail) s.Router.POST("/task/:id/move", s.handleMoveTask) s.Router.GET("/report/:id", s.handleReport) + + // Docs routes + s.Router.GET("/docs", s.handleDocsList) + s.Router.GET("/docs/*path", s.handleDocsView) } // apiGetTasks returns all tasks as JSON. diff --git a/code/internal/server/server_test.go b/code/internal/server/server_test.go index 4d2b94f..84c04b1 100644 --- a/code/internal/server/server_test.go +++ b/code/internal/server/server_test.go @@ -53,6 +53,12 @@ func setupTestServer(t *testing.T) *Server { os.WriteFile(filepath.Join(tasksDir, "done", "T01.md"), []byte(testTask1), 0644) os.WriteFile(filepath.Join(tasksDir, "backlog", "T08.md"), []byte(testTask2), 0644) + // Docs: create markdown files in project root + os.WriteFile(filepath.Join(dir, "CLAUDE.md"), []byte("# CLAUDE.md\n\nGlavni fajl.\n\n| Kolona | Opis |\n|--------|------|\n| A | B |\n"), 0644) + os.WriteFile(filepath.Join(dir, "README.md"), []byte("# README\n\nOpis projekta.\n"), 0644) + os.MkdirAll(filepath.Join(dir, "agents", "coder"), 0755) + os.WriteFile(filepath.Join(dir, "agents", "coder", "CLAUDE.md"), []byte("# Coder Agent\n\nPravila kodiranja.\n"), 0644) + cfg := &config.Config{ TasksDir: tasksDir, ProjectPath: dir, @@ -548,6 +554,142 @@ func TestDashboardReflectsDiskChanges(t *testing.T) { } } +func TestDocsList(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/docs", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + body := w.Body.String() + if !containsStr(body, "CLAUDE.md") { + t.Error("expected CLAUDE.md in docs list") + } + if !containsStr(body, "README.md") { + t.Error("expected README.md in docs list") + } + if !containsStr(body, "agents/coder/CLAUDE.md") { + t.Error("expected agents/coder/CLAUDE.md in docs list") + } +} + +func TestDocsView_CLAUDE(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/docs/CLAUDE.md", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + body := w.Body.String() + if !containsStr(body, "Glavni fajl") { + t.Error("expected rendered markdown content") + } + // Should have table rendered as HTML + if !containsStr(body, "") || !containsStr(body, "
") { + t.Error("expected HTML table from markdown") + } +} + +func TestDocsView_NestedFile(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/docs/agents/coder/CLAUDE.md", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + body := w.Body.String() + if !containsStr(body, "Coder Agent") { + t.Error("expected nested file content") + } + // Breadcrumbs + if !containsStr(body, "agents") { + t.Error("expected breadcrumb for agents") + } +} + +func TestDocsView_PathTraversal(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/docs/../../etc/passwd", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403 for path traversal, got %d", w.Code) + } +} + +func TestDocsView_NonMarkdown(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/docs/main.go", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403 for non-.md file, got %d", w.Code) + } +} + +func TestDocsView_NotFound(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/docs/nonexistent.md", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + +func TestDocsView_HasBreadcrumbs(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/docs/agents/coder/CLAUDE.md", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "Dokumenti") { + t.Error("expected 'Dokumenti' in breadcrumbs") + } + if !containsStr(body, "coder") { + t.Error("expected 'coder' in breadcrumbs") + } +} + +func TestRewriteLinksSimple(t *testing.T) { + input := `link and ext` + result := rewriteLinksSimple(input, ".") + if !containsStr(result, `/docs/README.md`) { + t.Errorf("expected rewritten link, got: %s", result) + } + if !containsStr(result, `https://example.com`) { + t.Error("external link should not be rewritten") + } +} + +func TestRewriteLinksSimple_NestedDir(t *testing.T) { + input := `link` + result := rewriteLinksSimple(input, "agents/coder") + if !containsStr(result, `/docs/agents/coder/CLAUDE.md`) { + t.Errorf("expected nested rewritten link, got: %s", result) + } +} + func containsStr(s, substr string) bool { return len(s) >= len(substr) && findStr(s, substr) } diff --git a/code/web/static/style.css b/code/web/static/style.css index 73b9d98..f5bffe3 100644 --- a/code/web/static/style.css +++ b/code/web/static/style.css @@ -226,6 +226,157 @@ body { color: #fff; } +/* Navigation */ +.nav { + display: flex; + gap: 8px; + align-items: center; +} + +.btn-active { + background: #0f3460; + border-color: #e94560; +} + +/* Docs */ +.docs-container { + padding: 16px 24px; + max-width: 960px; + margin: 0 auto; +} + +.docs-container h2 { + margin-bottom: 16px; + color: #e94560; +} + +.docs-list { + display: flex; + flex-direction: column; + gap: 4px; +} + +.doc-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: #16213e; + border-radius: 6px; + color: #eee; + text-decoration: none; + font-size: 0.9em; + font-family: "JetBrains Mono", "Fira Code", monospace; + transition: background 0.2s; +} + +.doc-item:hover { + background: #0f3460; +} + +.doc-icon { font-size: 1em; } + +.docs-breadcrumbs { + margin-bottom: 16px; + font-size: 0.85em; +} + +.docs-breadcrumbs a { + color: #e94560; + text-decoration: none; +} + +.docs-breadcrumbs a:hover { + text-decoration: underline; +} + +.breadcrumb-sep { + color: #555; + margin: 0 4px; +} + +.docs-content { + background: #16213e; + border-radius: 8px; + padding: 24px; + line-height: 1.7; + font-size: 0.95em; +} + +.docs-content h1, .docs-content h2, .docs-content h3 { + color: #e94560; + margin-top: 1.2em; + margin-bottom: 0.5em; +} + +.docs-content h1 { font-size: 1.5em; border-bottom: 1px solid #333; padding-bottom: 8px; } +.docs-content h2 { font-size: 1.2em; } +.docs-content h3 { font-size: 1.05em; } + +.docs-content a { + color: #4ecca3; + text-decoration: none; +} + +.docs-content a:hover { text-decoration: underline; } + +.docs-content code { + background: #1a1a2e; + padding: 2px 6px; + border-radius: 3px; + font-family: "JetBrains Mono", "Fira Code", monospace; + font-size: 0.9em; +} + +.docs-content pre { + background: #1a1a2e; + padding: 12px; + border-radius: 6px; + overflow-x: auto; + margin: 12px 0; +} + +.docs-content pre code { + background: none; + padding: 0; +} + +.docs-content table { + width: 100%; + border-collapse: collapse; + margin: 12px 0; +} + +.docs-content th, .docs-content td { + border: 1px solid #333; + padding: 8px 12px; + text-align: left; +} + +.docs-content th { + background: #0f3460; +} + +.docs-content ul, .docs-content ol { + padding-left: 24px; + margin: 8px 0; +} + +.docs-content li { margin: 4px 0; } + +.docs-content blockquote { + border-left: 3px solid #e94560; + padding-left: 12px; + color: #aaa; + margin: 12px 0; +} + +.docs-content hr { + border: none; + border-top: 1px solid #333; + margin: 16px 0; +} + /* Responsive */ @media (max-width: 1100px) { .board { grid-template-columns: repeat(3, 1fr); } diff --git a/code/web/templates/docs-list.html b/code/web/templates/docs-list.html new file mode 100644 index 0000000..27284b3 --- /dev/null +++ b/code/web/templates/docs-list.html @@ -0,0 +1,33 @@ +{{define "docs-list"}} + + + + + +KAOS — Dokumenti + + + + +
+

🔧 KAOS Dashboard

+ +
+
+

Dokumentacija

+
+ {{range .Files}} + + 📄 + {{.Name}} + + {{end}} +
+
+
+ + +{{end}} diff --git a/code/web/templates/docs-view.html b/code/web/templates/docs-view.html new file mode 100644 index 0000000..ab694ff --- /dev/null +++ b/code/web/templates/docs-view.html @@ -0,0 +1,33 @@ +{{define "docs-view"}} + + + + + +KAOS — {{.Path}} + + + + +
+

🔧 KAOS Dashboard

+ +
+
+
+ Dokumenti + {{range .Breadcrumbs}} + + {{.Name}} + {{end}} +
+
+ {{.HTML}} +
+
+ + +{{end}} diff --git a/code/web/templates/layout.html b/code/web/templates/layout.html index 7d9dfd1..265a558 100644 --- a/code/web/templates/layout.html +++ b/code/web/templates/layout.html @@ -11,7 +11,10 @@

🔧 KAOS Dashboard

- v0.2 +
{{block "content" .}}{{end}}