fix: master key deploy now logged in deployment history, add flash feedback after deploy

This commit is contained in:
2026-04-09 15:13:41 +02:00
parent da6d66e048
commit 07ea917726
5 changed files with 78 additions and 12 deletions
+3 -1
View File
@@ -77,7 +77,9 @@ The key is deployed to all servers in the group sequentially.
### Deployment History
The deploy page shows the last 50 deployment results with status (success/failed) and error messages.
The deploy page shows the last 50 deployment results with status (success/failed) and error messages. Master key deployments are included in the history as **[MASTER] System Master Key**.
After each deployment (single host or group), a flash message is displayed at the top of the page indicating success or failure.
## Access Assignments
+8 -3
View File
@@ -48,7 +48,7 @@ func (d *DB) ExportAll() (*BackupData, error) {
{`SELECT id, user_id, name, hostname, port, username, description, created_at, updated_at FROM servers ORDER BY id`, &backup.Servers},
{`SELECT id, user_id, name, description, created_at, updated_at FROM server_groups ORDER BY id`, &backup.ServerGroups},
{`SELECT id, group_id, server_id FROM server_group_members ORDER BY id`, &backup.GroupMembers},
{`SELECT id, ssh_key_id, server_id, deployed_at, status, message FROM key_deployments ORDER BY id`, &backup.KeyDeployments},
{`SELECT id, ssh_key_id, server_id, deployed_at, status, message, key_name FROM key_deployments ORDER BY id`, &backup.KeyDeployments},
{`SELECT id, user_id, action, details, ip_address, created_at FROM audit_log ORDER BY id`, &backup.AuditLog},
{`SELECT key, value, updated_at FROM settings ORDER BY key`, &backup.Settings},
{`SELECT id, user_id, ssh_key_id, server_id, group_id, system_user, desired_state, sudo, create_user, initial_password, status, last_sync_at, created_at, updated_at FROM access_assignments ORDER BY id`, &backup.AccessAssign},
@@ -115,7 +115,7 @@ func (d *DB) ImportAll(backup *BackupData) error {
{"servers", []string{"id", "user_id", "name", "hostname", "port", "username", "description", "created_at", "updated_at"}, backup.Servers},
{"server_groups", []string{"id", "user_id", "name", "description", "created_at", "updated_at"}, backup.ServerGroups},
{"server_group_members", []string{"id", "group_id", "server_id"}, backup.GroupMembers},
{"key_deployments", []string{"id", "ssh_key_id", "server_id", "deployed_at", "status", "message"}, backup.KeyDeployments},
{"key_deployments", []string{"id", "ssh_key_id", "server_id", "deployed_at", "status", "message", "key_name"}, backup.KeyDeployments},
{"audit_log", []string{"id", "user_id", "action", "details", "ip_address", "created_at"}, backup.AuditLog},
{"settings", []string{"key", "value", "updated_at"}, backup.Settings},
{"access_assignments", []string{"id", "user_id", "ssh_key_id", "server_id", "group_id", "system_user", "desired_state", "sudo", "create_user", "initial_password", "status", "last_sync_at", "created_at", "updated_at"}, backup.AccessAssign},
@@ -145,7 +145,12 @@ func (d *DB) ImportAll(backup *BackupData) error {
for _, row := range imp.data {
args := make([]interface{}, len(imp.columns))
for i, col := range imp.columns {
args[i] = row[col]
v := row[col]
// For columns with NOT NULL DEFAULT '', treat nil as empty string
if v == nil && col == "key_name" {
v = ""
}
args[i] = v
}
if _, err := stmt.Exec(args...); err != nil {
stmt.Close()
+36
View File
@@ -5,6 +5,7 @@
package database
import (
"context"
"database/sql"
"fmt"
"os"
@@ -266,5 +267,40 @@ func (d *DB) migrate() error {
}
}
// Migration: recreate key_deployments with nullable ssh_key_id and key_name column
// This is needed so the system master key (which has no ssh_keys row) can be logged.
{
var migCount int
d.QueryRow(`SELECT COUNT(*) FROM _migrations WHERE name = 'key_deployments_nullable_keyid'`).Scan(&migCount)
if migCount == 0 {
ctx := context.Background()
conn, err := d.DB.Conn(ctx)
if err == nil {
conn.ExecContext(ctx, `PRAGMA foreign_keys = OFF`)
conn.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS key_deployments_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ssh_key_id INTEGER,
server_id INTEGER NOT NULL,
deployed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
status TEXT NOT NULL DEFAULT 'pending',
message TEXT,
key_name TEXT NOT NULL DEFAULT '',
FOREIGN KEY (ssh_key_id) REFERENCES ssh_keys(id) ON DELETE CASCADE,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
)`)
conn.ExecContext(ctx, `INSERT INTO key_deployments_new (id, ssh_key_id, server_id, deployed_at, status, message, key_name)
SELECT kd.id, kd.ssh_key_id, kd.server_id, kd.deployed_at, kd.status, kd.message,
COALESCE(sk.name, '')
FROM key_deployments kd
LEFT JOIN ssh_keys sk ON kd.ssh_key_id = sk.id`)
conn.ExecContext(ctx, `DROP TABLE key_deployments`)
conn.ExecContext(ctx, `ALTER TABLE key_deployments_new RENAME TO key_deployments`)
conn.ExecContext(ctx, `PRAGMA foreign_keys = ON`)
conn.Close()
}
d.Exec(`INSERT INTO _migrations (name) VALUES ('key_deployments_nullable_keyid')`)
}
}
return nil
}
+19 -7
View File
@@ -615,20 +615,32 @@ func (s *Service) TestSSHAuth(hostname string, port int, username string, privat
// logDeployment records a deployment attempt
func (s *Service) logDeployment(keyID, serverID int64, status, message string) {
s.db.Exec(
`INSERT INTO key_deployments (ssh_key_id, server_id, status, message) VALUES (?, ?, ?, ?)`,
keyID, serverID, status, message,
)
if keyID <= 0 {
// Master key or virtual key: store with NULL ssh_key_id
s.db.Exec(
`INSERT INTO key_deployments (ssh_key_id, server_id, status, message, key_name) VALUES (NULL, ?, ?, ?, '[MASTER] System Master Key')`,
serverID, status, message,
)
} else {
var keyName string
s.db.QueryRow(`SELECT name FROM ssh_keys WHERE id = ?`, keyID).Scan(&keyName)
s.db.Exec(
`INSERT INTO key_deployments (ssh_key_id, server_id, status, message, key_name) VALUES (?, ?, ?, ?, ?)`,
keyID, serverID, status, message, keyName,
)
}
}
// GetDeployments returns deployment history for a user's keys
func (s *Service) GetDeployments(userID int64) ([]map[string]interface{}, error) {
rows, err := s.db.Query(
`SELECT kd.id, sk.name as key_name, srv.name as server_name, kd.status, kd.message, kd.deployed_at
`SELECT kd.id,
CASE WHEN kd.key_name != '' THEN kd.key_name ELSE COALESCE(sk.name, 'Unknown') END as key_name,
srv.name as server_name, kd.status, kd.message, kd.deployed_at
FROM key_deployments kd
JOIN ssh_keys sk ON kd.ssh_key_id = sk.id
LEFT JOIN ssh_keys sk ON kd.ssh_key_id = sk.id
JOIN servers srv ON kd.server_id = srv.id
WHERE sk.user_id = ?
WHERE sk.user_id = ? OR kd.ssh_key_id IS NULL
ORDER BY kd.deployed_at DESC LIMIT 50`, userID,
)
if err != nil {
+12 -1
View File
@@ -1624,7 +1624,18 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request) {
logging.Info("Deploy successful: key='%s' target=%s@%s:%d", key.Name, server.Username, server.Hostname, server.Port)
h.audit.Log(userID, audit.ActionDeploySuccess, fmt.Sprintf("Deployed key '%s' to %s@%s:%d", key.Name, server.Username, server.Hostname, server.Port), clientIP(r))
http.Redirect(w, r, "/deploy", http.StatusSeeOther)
deployments, _ = h.deploy.GetDeployments(userID)
data := &PageData{
Title: "Deploy Keys",
Active: "deploy",
User: user,
Keys: keyList,
Servers: serverList,
Groups: groups,
Deployments: deployments,
Flash: &Flash{Type: "success", Message: fmt.Sprintf("Key '%s' successfully deployed to %s@%s:%d.", key.Name, server.Username, server.Hostname, server.Port)},
}
h.templates["deploy"].ExecuteTemplate(w, "base", data)
}
func (h *Handler) handleDeployGroup(w http.ResponseWriter, r *http.Request) {