hub: unified customer page, blocked status, dashboard merge

- Replace separate config detail and report detail pages with unified
  /customers/{id} page showing both config info and live report data
- Add "blocked" status for customers (hidden from dashboard, notifications
  suppressed, still accepts reports)
- Dashboard now shows config-only customers as "PENDING" status
- Customers list: all rows link to /customers/{id}, show BLOCKED badge
- New actions: block/unblock, push config to controller, auto-create
  config from report data
- /configs/{id} now redirects to /customers/{id}
- Add config-badge CSS classes for MANAGED/MANUAL/BLOCKED badges

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 15:57:39 +01:00
parent cb425d8086
commit 42e0617a6c
9 changed files with 1017 additions and 168 deletions
+30 -6
View File
@@ -118,6 +118,9 @@ func (s *Store) migrate() error {
// v0.1.8: add controller_url column (idempotent — ignore error if already exists)
s.db.Exec("ALTER TABLE reports ADD COLUMN controller_url TEXT")
// v0.2.1: add status column to customer_configs (idempotent)
s.db.Exec("ALTER TABLE customer_configs ADD COLUMN status TEXT NOT NULL DEFAULT 'active'")
return nil
}
@@ -520,6 +523,7 @@ type CustomerConfig struct {
RetrievalPassword string
APIKey string
ConfigJSON string // JSON object with customer-specific override fields
Status string // "active" or "blocked"
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -550,11 +554,11 @@ func (s *Store) GetCustomerConfig(customerID string) (*CustomerConfig, error) {
var createdAt, updatedAt string
err := s.db.QueryRow(`
SELECT customer_id, customer_name, domain, email,
retrieval_password, api_key, config_json, created_at, updated_at
retrieval_password, api_key, config_json, status, created_at, updated_at
FROM customer_configs WHERE customer_id = ?`,
customerID,
).Scan(&cfg.CustomerID, &cfg.CustomerName, &cfg.Domain, &cfg.Email,
&cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON,
&cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON, &cfg.Status,
&createdAt, &updatedAt)
if err == sql.ErrNoRows {
return nil, nil
@@ -571,7 +575,7 @@ func (s *Store) GetCustomerConfig(customerID string) (*CustomerConfig, error) {
func (s *Store) ListCustomerConfigs() ([]CustomerConfig, error) {
rows, err := s.db.Query(`
SELECT customer_id, customer_name, domain, email,
retrieval_password, api_key, config_json, created_at, updated_at
retrieval_password, api_key, config_json, status, created_at, updated_at
FROM customer_configs ORDER BY customer_id`)
if err != nil {
return nil, err
@@ -583,7 +587,7 @@ func (s *Store) ListCustomerConfigs() ([]CustomerConfig, error) {
var cfg CustomerConfig
var createdAt, updatedAt string
if err := rows.Scan(&cfg.CustomerID, &cfg.CustomerName, &cfg.Domain, &cfg.Email,
&cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON,
&cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON, &cfg.Status,
&createdAt, &updatedAt); err != nil {
return nil, err
}
@@ -607,11 +611,11 @@ func (s *Store) GetCustomerConfigByAPIKey(apiKey string) (*CustomerConfig, error
var createdAt, updatedAt string
err := s.db.QueryRow(`
SELECT customer_id, customer_name, domain, email,
retrieval_password, api_key, config_json, created_at, updated_at
retrieval_password, api_key, config_json, status, created_at, updated_at
FROM customer_configs WHERE api_key = ?`,
apiKey,
).Scan(&cfg.CustomerID, &cfg.CustomerName, &cfg.Domain, &cfg.Email,
&cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON,
&cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON, &cfg.Status,
&createdAt, &updatedAt)
if err == sql.ErrNoRows {
return nil, nil
@@ -624,6 +628,26 @@ func (s *Store) GetCustomerConfigByAPIKey(apiKey string) (*CustomerConfig, error
return &cfg, nil
}
// SetCustomerConfigStatus sets the status (active/blocked) for a customer config.
func (s *Store) SetCustomerConfigStatus(customerID, status string) error {
_, err := s.db.Exec(`
UPDATE customer_configs SET status = ?, updated_at = datetime('now')
WHERE customer_id = ?`,
status, customerID,
)
return err
}
// IsCustomerBlocked returns true if the customer config has status "blocked".
func (s *Store) IsCustomerBlocked(customerID string) bool {
var status string
err := s.db.QueryRow(
"SELECT status FROM customer_configs WHERE customer_id = ?",
customerID,
).Scan(&status)
return err == nil && status == "blocked"
}
// UpdateRetrievalPassword updates the retrieval password for a customer config.
func (s *Store) UpdateRetrievalPassword(customerID, newPassword string) error {
_, err := s.db.Exec(`