Skip to content

Commit 2af7803

Browse files
Refine StackHost product UX
1 parent 549078b commit 2af7803

22 files changed

Lines changed: 726 additions & 62 deletions

‎README.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# StackHost
22

3-
StackHost is an early-stage, self-hosted control panel for Docker and Docker Swarm. It provides first-admin onboarding, cookie sessions, SQLite persistence, projects, and an infrastructure-aware dashboard.
3+
StackHost is an early-stage, self-hosted control panel for Docker and Docker Swarm. It provides first-admin onboarding, cookie sessions, SQLite persistence, projects, applications, Docker inventory, and an infrastructure-aware dashboard in Portuguese (Brasil).
44

55
## Local development
66

@@ -16,6 +16,10 @@ Open http://localhost:8080. A clean database redirects users to `/setup` in the
1616

1717
For frontend hot reload during development, run `cd web && npm run dev` in a second terminal. The Vite server proxies the UI only; the Go server remains responsible for the API.
1818

19+
When Docker is connected without Swarm, open **Infraestrutura** and use **Preparar ambiente**. The operation uses the Docker Engine SDK, requires an administrator session, records an audit event, and never removes existing containers, images, or volumes. Docker socket access is intentionally explicit because it grants elevated access to the host.
20+
21+
The initial read-only inventory APIs are `/api/v1/infrastructure/containers`, `/images`, `/volumes`, and `/networks`; each accepts `limit` and returns bounded DTOs.
22+
1923
## Docker
2024

2125
```bash

‎cmd/stackhost/main.go‎

Lines changed: 137 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,11 @@ func (a *app) routes() http.Handler {
120120
mux.HandleFunc("/api/v1/infrastructure", a.auth(a.infrastructure))
121121
mux.HandleFunc("/api/v1/infrastructure/nodes", a.auth(a.infrastructureNodes))
122122
mux.HandleFunc("/api/v1/infrastructure/services", a.auth(a.infrastructureServices))
123+
mux.HandleFunc("/api/v1/infrastructure/containers", a.auth(a.infrastructureContainers))
124+
mux.HandleFunc("/api/v1/infrastructure/images", a.auth(a.infrastructureImages))
125+
mux.HandleFunc("/api/v1/infrastructure/volumes", a.auth(a.infrastructureVolumes))
126+
mux.HandleFunc("/api/v1/infrastructure/networks", a.auth(a.infrastructureNetworks))
127+
mux.HandleFunc("/api/v1/infrastructure/swarm/init", a.auth(a.infrastructureSwarmInit))
123128
mux.HandleFunc("/api/v1/activity", a.auth(a.activity))
124129
mux.HandleFunc("/api/v1/events", a.auth(a.eventsStream))
125130
mux.HandleFunc("/", a.spa)
@@ -336,17 +341,31 @@ func (a *app) dashboard(w http.ResponseWriter, r *http.Request) {
336341
for rows.Next() {
337342
var action, resource, created string
338343
rows.Scan(&action, &resource, &created)
339-
recent = append(recent, map[string]string{"action": action, "resource_type": resource, "created_at": created})
344+
recent = append(recent, map[string]string{"action": action, "resource_type": resource, "created_at": created, "description": activityDescription(action)})
340345
}
341346
}
342-
json.NewEncoder(w).Encode(map[string]any{"projects": projects, "applications": apps, "infrastructure": infra, "activity": recent})
347+
recentProjects := []any{}
348+
if rows, err := a.db.Query("SELECT id,name,(SELECT count(*) FROM applications a WHERE a.project_id=projects.id),updated_at FROM projects ORDER BY updated_at DESC LIMIT 4"); err == nil {
349+
defer rows.Close()
350+
for rows.Next() {
351+
var id, count int
352+
var name, updated string
353+
_ = rows.Scan(&id, &name, &count, &updated)
354+
recentProjects = append(recentProjects, map[string]any{"id": id, "name": name, "applications_count": count, "updated_at": updated})
355+
}
356+
}
357+
json.NewEncoder(w).Encode(map[string]any{"projects": projects, "applications": apps, "infrastructure": infra, "activity": recent, "recent_projects": recentProjects})
358+
}
359+
func activityDescription(action string) string {
360+
descriptions := map[string]string{"login": "Entrou no painel", "logout": "Saiu do painel", "onboarding": "Criou o administrador inicial", "project.created": "Criou um projeto", "project.updated": "Atualizou um projeto", "application.created": "Adicionou uma aplicação", "application.updated": "Atualizou uma aplicação", "swarm.initialized": "Preparou o ambiente Docker"}
361+
return descriptions[action]
343362
}
344363
func (a *app) audit(userID int64, action, resource string, resourceID any) {
345364
now := time.Now().UTC().Format(time.RFC3339)
346365
a.db.Exec("INSERT INTO audit_logs(user_id,action,resource_type,resource_id,created_at) VALUES(?,?,?,?,?)", userID, action, resource, fmt.Sprint(resourceID), now)
347366
}
348367
func (a *app) activity(w http.ResponseWriter, r *http.Request) {
349-
rows, err := a.db.Query("SELECT id,action,coalesce(resource_type,''),coalesce(resource_id,''),created_at FROM audit_logs ORDER BY id DESC LIMIT 50")
368+
rows, err := a.db.Query("SELECT audit_logs.id,audit_logs.action,coalesce(audit_logs.resource_type,''),coalesce(audit_logs.resource_id,''),audit_logs.created_at,coalesce(users.name,'Administrador') FROM audit_logs LEFT JOIN users ON users.id=audit_logs.user_id ORDER BY audit_logs.id DESC LIMIT 50")
350369
if err != nil {
351370
jsonError(w, 500, "internal_error", "Não foi possível carregar a atividade.")
352371
return
@@ -355,23 +374,24 @@ func (a *app) activity(w http.ResponseWriter, r *http.Request) {
355374
out := []any{}
356375
for rows.Next() {
357376
var id int
358-
var action, resource, resourceID, created string
359-
rows.Scan(&id, &action, &resource, &resourceID, &created)
360-
out = append(out, map[string]any{"id": id, "action": action, "resource_type": resource, "resource_id": resourceID, "created_at": created})
377+
var action, resource, resourceID, created, actor string
378+
rows.Scan(&id, &action, &resource, &resourceID, &created, &actor)
379+
out = append(out, map[string]any{"id": id, "action": action, "description": activityDescription(action), "resource_type": resource, "resource_id": resourceID, "actor_name": actor, "created_at": created})
361380
}
362381
json.NewEncoder(w).Encode(out)
363382
}
364383
func (a *app) projects(w http.ResponseWriter, r *http.Request) {
365384
w.Header().Set("Content-Type", "application/json")
366385
if r.Method == "GET" {
367-
rows, _ := a.db.Query("SELECT id,name,slug,coalesce(description,''),status,created_at,updated_at FROM projects ORDER BY id DESC")
386+
rows, _ := a.db.Query("SELECT id,name,slug,coalesce(description,''),status,created_at,updated_at,(SELECT count(*) FROM applications a WHERE a.project_id=projects.id) FROM projects ORDER BY id DESC")
368387
defer rows.Close()
369388
out := []any{}
370389
for rows.Next() {
371390
var id int
372391
var name, slug, desc, status, created, updated string
373-
rows.Scan(&id, &name, &slug, &desc, &status, &created, &updated)
374-
out = append(out, map[string]any{"id": id, "name": name, "slug": slug, "description": desc, "status": status, "created_at": created, "updated_at": updated})
392+
var applicationsCount int
393+
rows.Scan(&id, &name, &slug, &desc, &status, &created, &updated, &applicationsCount)
394+
out = append(out, map[string]any{"id": id, "name": name, "slug": slug, "description": desc, "status": status, "applications_count": applicationsCount, "created_at": created, "updated_at": updated})
375395
}
376396
json.NewEncoder(w).Encode(out)
377397
return
@@ -477,7 +497,12 @@ func (a *app) applications(w http.ResponseWriter, r *http.Request, projectID int
477497
jsonError(w, 405, "method_not_allowed", "Método não permitido.")
478498
return
479499
}
480-
var in struct{ Name, Slug, SourceType, DockerStackName string }
500+
var in struct {
501+
Name string `json:"name"`
502+
Slug string `json:"slug"`
503+
SourceType string `json:"source_type"`
504+
DockerStackName string `json:"docker_stack_name"`
505+
}
481506
if json.NewDecoder(r.Body).Decode(&in) != nil {
482507
jsonValidation(w, map[string]string{"form": "JSON inválido."})
483508
return
@@ -536,7 +561,11 @@ func (a *app) applicationRoute(w http.ResponseWriter, r *http.Request) {
536561
jsonError(w, 405, "method_not_allowed", "Método não permitido.")
537562
return
538563
}
539-
var in struct{ Name, Status, DockerStackName string }
564+
var in struct {
565+
Name string `json:"name"`
566+
Status string `json:"status"`
567+
DockerStackName string `json:"docker_stack_name"`
568+
}
540569
if json.NewDecoder(r.Body).Decode(&in) != nil {
541570
jsonError(w, 422, "validation_failed", "Revise os campos informados.")
542571
return
@@ -623,6 +652,103 @@ func (a *app) infrastructureServices(w http.ResponseWriter, r *http.Request) {
623652
}
624653
json.NewEncoder(w).Encode(services)
625654
}
655+
func inventoryLimit(r *http.Request) int {
656+
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
657+
if limit <= 0 {
658+
return 50
659+
}
660+
if limit > 200 {
661+
return 200
662+
}
663+
return limit
664+
}
665+
func (a *app) infrastructureContainers(w http.ResponseWriter, r *http.Request) {
666+
if a.docker == nil {
667+
jsonError(w, 503, "docker_unavailable", "Docker não está conectado.")
668+
return
669+
}
670+
items, err := a.docker.Containers(r.Context(), inventoryLimit(r), r.URL.Query().Get("state"))
671+
if err != nil {
672+
jsonError(w, 503, "docker_unavailable", "Não foi possível consultar os containers.")
673+
return
674+
}
675+
json.NewEncoder(w).Encode(items)
676+
}
677+
func (a *app) infrastructureImages(w http.ResponseWriter, r *http.Request) {
678+
if a.docker == nil {
679+
jsonError(w, 503, "docker_unavailable", "Docker não está conectado.")
680+
return
681+
}
682+
items, err := a.docker.Images(r.Context(), inventoryLimit(r))
683+
if err != nil {
684+
jsonError(w, 503, "docker_unavailable", "Não foi possível consultar as imagens.")
685+
return
686+
}
687+
json.NewEncoder(w).Encode(items)
688+
}
689+
func (a *app) infrastructureVolumes(w http.ResponseWriter, r *http.Request) {
690+
if a.docker == nil {
691+
jsonError(w, 503, "docker_unavailable", "Docker não está conectado.")
692+
return
693+
}
694+
items, err := a.docker.Volumes(r.Context(), inventoryLimit(r))
695+
if err != nil {
696+
jsonError(w, 503, "docker_unavailable", "Não foi possível consultar os volumes.")
697+
return
698+
}
699+
json.NewEncoder(w).Encode(items)
700+
}
701+
func (a *app) infrastructureNetworks(w http.ResponseWriter, r *http.Request) {
702+
if a.docker == nil {
703+
jsonError(w, 503, "docker_unavailable", "Docker não está conectado.")
704+
return
705+
}
706+
items, err := a.docker.Networks(r.Context(), inventoryLimit(r))
707+
if err != nil {
708+
jsonError(w, 503, "docker_unavailable", "Não foi possível consultar as redes.")
709+
return
710+
}
711+
json.NewEncoder(w).Encode(items)
712+
}
713+
func (a *app) infrastructureSwarmInit(w http.ResponseWriter, r *http.Request) {
714+
if r.Method != http.MethodPost {
715+
jsonError(w, 405, "method_not_allowed", "Método não permitido.")
716+
return
717+
}
718+
userID, _ := r.Context().Value(userKey{}).(int64)
719+
var role string
720+
if a.db.QueryRow("SELECT role FROM users WHERE id=?", userID).Scan(&role) != nil || role != "admin" {
721+
jsonError(w, 403, "permission_denied", "Somente administradores podem preparar o ambiente.")
722+
return
723+
}
724+
if a.docker == nil {
725+
jsonError(w, 503, "docker_unavailable", "Docker não está conectado.")
726+
return
727+
}
728+
current := a.docker.Snapshot(r.Context())
729+
if current.Swarm.Active {
730+
json.NewEncoder(w).Encode(map[string]any{"status": "active", "node_role": "manager", "message": "O ambiente já está preparado."})
731+
return
732+
}
733+
var input struct {
734+
AdvertiseAddress string `json:"advertise_address"`
735+
}
736+
if r.Body != nil {
737+
_ = json.NewDecoder(r.Body).Decode(&input)
738+
}
739+
clusterID, err := a.docker.InitSwarm(r.Context(), strings.TrimSpace(input.AdvertiseAddress))
740+
if err != nil {
741+
if strings.Contains(strings.ToLower(err.Error()), "advertise") || strings.Contains(strings.ToLower(err.Error()), "address") {
742+
jsonError(w, http.StatusUnprocessableEntity, "advertise_address_required", "O Docker precisa de um endereço de anúncio. Abra as opções avançadas e informe o endereço desta máquina.")
743+
return
744+
}
745+
jsonError(w, 422, "swarm_init_failed", "Não foi possível preparar o ambiente Docker. Verifique a rede do servidor e tente novamente.")
746+
return
747+
}
748+
a.audit(userID, "swarm.initialized", "infrastructure", clusterID)
749+
a.publish("swarm.initialized", map[string]any{"status": "active"})
750+
json.NewEncoder(w).Encode(map[string]any{"status": "active", "cluster_id": clusterID, "node_role": "manager", "message": "Ambiente preparado com sucesso."})
751+
}
626752
func (a *app) spa(w http.ResponseWriter, r *http.Request) {
627753
if strings.HasPrefix(r.URL.Path, "/api/") {
628754
http.NotFound(w, r)

‎cmd/stackhost/main_test.go‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package main
22

33
import (
4+
"context"
45
"database/sql"
56
"encoding/json"
67
"net/http"
@@ -178,3 +179,29 @@ func TestCleanDatabaseIntegrationFlow(t *testing.T) {
178179
t.Fatalf("dashboard payload = %#v", payload)
179180
}
180181
}
182+
183+
func TestInfrastructureInitRequiresAdmin(t *testing.T) {
184+
a := testApp(t)
185+
result, err := a.db.Exec("INSERT INTO users(name,email,password_hash,role,created_at,updated_at) VALUES(?,?,?,?,?,?)", "User", "user@example.com", "hash", "member", "now", "now")
186+
if err != nil {
187+
t.Fatal(err)
188+
}
189+
id, _ := result.LastInsertId()
190+
req := httptest.NewRequest(http.MethodPost, "/api/v1/infrastructure/swarm/init", strings.NewReader(`{}`)).WithContext(context.WithValue(context.Background(), userKey{}, id))
191+
w := httptest.NewRecorder()
192+
a.infrastructureSwarmInit(w, req)
193+
if w.Code != http.StatusForbidden {
194+
t.Fatalf("non-admin init status = %d", w.Code)
195+
}
196+
}
197+
198+
func TestInventoryLimitIsBounded(t *testing.T) {
199+
request := httptest.NewRequest(http.MethodGet, "/api/v1/infrastructure/containers?limit=999", nil)
200+
if got := inventoryLimit(request); got != 200 {
201+
t.Fatalf("maximum inventory limit = %d", got)
202+
}
203+
request = httptest.NewRequest(http.MethodGet, "/api/v1/infrastructure/containers?limit=0", nil)
204+
if got := inventoryLimit(request); got != 50 {
205+
t.Fatalf("default inventory limit = %d", got)
206+
}
207+
}

0 commit comments

Comments
 (0)