detached 10909c5

refactor: Update session management and template rendering

- Renamed `is_online` field to `is_active` in the Sessions struct for clarity.
- Updated SQL queries and session validation logic to reflect the new field name.
- Enhanced login route to validate session against the database, improving security.
- Refactored HTML rendering to utilize Templ components for better maintainability.
- Removed deprecated HTML fragments and streamlined template structure.
- Adjusted CSS styles for improved UI consistency and responsiveness.
commit
10909c5743ca9f579686073e1be47b2bc397d03b
author
bamen · · 2025-03-30 09:14 UTC
parent
0ea01e3
signature
unsigned
40 files · +402 -1515
M go.mod +2 -1
@@ -5,7 +5,6 @@ go 1.24.1
55 require (
66 github.com/AlecAivazis/survey/v2 v2.3.7
77 github.com/Masterminds/semver/v3 v3.3.1
8- github.com/PuerkitoBio/goquery v1.10.2
98 github.com/a-h/templ v0.3.833
109 github.com/charmbracelet/bubbles v0.20.0
1110 github.com/charmbracelet/lipgloss v1.0.0
@@ -25,6 +24,8 @@ require (
2524 modernc.org/sqlite v1.36.0
2625 )
2726
27+require github.com/PuerkitoBio/goquery v1.10.2 // indirect
28+
2829 require (
2930 cloud.google.com/go/auth v0.13.0 // indirect
3031 cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
@@ -5,7 +5,6 @@ go 1.24.1
5 require (5 require (
6 github.com/AlecAivazis/survey/v2 v2.3.76 github.com/AlecAivazis/survey/v2 v2.3.7
7 github.com/Masterminds/semver/v3 v3.3.17 github.com/Masterminds/semver/v3 v3.3.1
8- github.com/PuerkitoBio/goquery v1.10.2
9 github.com/a-h/templ v0.3.8338 github.com/a-h/templ v0.3.833
10 github.com/charmbracelet/bubbles v0.20.09 github.com/charmbracelet/bubbles v0.20.0
11 github.com/charmbracelet/lipgloss v1.0.010 github.com/charmbracelet/lipgloss v1.0.0
@@ -25,6 +24,8 @@ require (
25 modernc.org/sqlite v1.36.024 modernc.org/sqlite v1.36.0
26 )25 )
27 26
27+require github.com/PuerkitoBio/goquery v1.10.2 // indirect
28+
28 require (29 require (
29 cloud.google.com/go/auth v0.13.0 // indirect30 cloud.google.com/go/auth v0.13.0 // indirect
30 cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect31 cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
M go.sum +0 -2 generated file: collapsed
M internal/db/db_struct.go +1 -1
@@ -26,7 +26,7 @@ type Sessions struct {
2626 BrowserInfo string `sql:"browser_info"`
2727 AccessToken string `sql:"access_token"`
2828 Expires string `sql:"expires"`
29- IsOnline bool `sql:"is_online"`
29+ IsActive bool `sql:"is_active"`
3030 }
3131
3232 type Provider struct {
@@ -26,7 +26,7 @@ type Sessions struct {
26 BrowserInfo string `sql:"browser_info"`26 BrowserInfo string `sql:"browser_info"`
27 AccessToken string `sql:"access_token"`27 AccessToken string `sql:"access_token"`
28 Expires string `sql:"expires"`28 Expires string `sql:"expires"`
29- IsOnline bool `sql:"is_online"`29+ IsActive bool `sql:"is_active"`
30 }30 }
31 31
32 type Provider struct {32 type Provider struct {
M internal/db/queries/sessions.go +62 -22
@@ -20,19 +20,21 @@ type SessionQueries struct {
2020 UpdateSessionExpiry string
2121 CheckSessionExists string
2222 InvalidateSession string
23+ MarkSessionActive string
2324 }
2425
2526 // NewSessionQueries returns a new instance of SessionQueries
2627 func NewSessionQueries() *SessionQueries {
2728 return &SessionQueries{
28- InsertSession: "INSERT INTO sessions (id, account_id, access_token, browser_info, expires, is_online) VALUES (?, ?, ?, ?, ?, ?)",
29- GetSessionByToken: "SELECT sessions.id, sessions.account_id, sessions.access_token, sessions.browser_info, sessions.expires, sessions.is_online FROM sessions INNER JOIN account ON sessions.account_id = account.id INNER JOIN provider ON account.id = provider.account_id WHERE sessions.access_token = ? AND sessions.browser_info = ?",
30- UpdateSessionToken: "UPDATE sessions SET access_token = ?, expires = ? WHERE id = ?",
29+ InsertSession: "INSERT INTO sessions (id, account_id, access_token, browser_info, expires, is_active) VALUES (?, ?, ?, ?, ?, ?)",
30+ GetSessionByToken: "SELECT sessions.id, sessions.account_id, sessions.access_token, sessions.browser_info, sessions.expires, sessions.is_active FROM sessions INNER JOIN account ON sessions.account_id = account.id INNER JOIN provider ON account.id = provider.account_id WHERE sessions.access_token = ? AND sessions.browser_info = ?",
31+ UpdateSessionToken: "UPDATE sessions SET access_token = ?, expires = ?, is_active = true WHERE id = ?",
3132 DeleteSession: "DELETE FROM sessions WHERE account_id = ? AND id = ?",
3233 GetSessionExpiry: "SELECT expires FROM sessions WHERE account_id = ? AND id = ?",
3334 UpdateSessionExpiry: "UPDATE sessions SET expires = ? WHERE account_id = ? AND id = ?",
3435 CheckSessionExists: "SELECT EXISTS(SELECT 1 FROM sessions WHERE id = ?)",
35- InvalidateSession: "UPDATE sessions SET is_online = ? WHERE id = ?",
36+ InvalidateSession: "UPDATE sessions SET is_active = ? WHERE id = ?",
37+ MarkSessionActive: "UPDATE sessions SET is_active = true WHERE id = ?",
3638 }
3739 }
3840
@@ -58,7 +60,7 @@ func CreateDBSession(database *sql.DB, browserInfo string, accessToken string, a
5860 AccessToken: accessToken,
5961 BrowserInfo: browserInfo,
6062 Expires: expireTime,
61- IsOnline: true,
63+ IsActive: true,
6264 }
6365
6466 logger.Debug("Session created in database", "session_id", sessionID, "account_id", accountID, "expires", expireTime)
@@ -79,7 +81,7 @@ func GetDBUserSession(database *sql.DB, accessToken string, browserInfo string)
7981 &session.AccessToken,
8082 &session.BrowserInfo,
8183 &session.Expires,
82- &session.IsOnline,
84+ &session.IsActive,
8385 )
8486
8587 if err != nil {
@@ -130,12 +132,19 @@ func CreateOrUpdateSession(database *sql.DB, accountID string, accessToken strin
130132 // If the session exists, update its token and expiry
131133 if existingSession != nil {
132134 logger.Debug("Existing session found, updating token and expiry", "sessionID", existingSession.ID, "accountID", accountID)
133- _, err := database.Exec(
134- NewSessionQueries().UpdateSessionToken,
135- accessToken,
136- expireTime,
137- existingSession.ID,
135+ updateQuery := NewSessionQueries().UpdateSessionToken
136+ updateParams := []interface{}{accessToken, expireTime, existingSession.ID}
137+ logger.Debug("Executing session update", "query", updateQuery, "params", updateParams)
138+ result, err := database.Exec(
139+ updateQuery,
140+ updateParams...,
138141 )
142+ if err != nil {
143+ logger.Error("Database Exec failed during session update", "error", err, "session_id", existingSession.ID)
144+ } else {
145+ rowsAffected, _ := result.RowsAffected()
146+ logger.Debug("Database Exec success during session update", "session_id", existingSession.ID, "rows_affected", rowsAffected)
147+ }
139148 if err != nil {
140149 logger.Error("Failed to update existing session", "error", err, "session_id", existingSession.ID)
141150 return nil, fmt.Errorf("error updating session: %w", err)
@@ -143,17 +152,27 @@ func CreateOrUpdateSession(database *sql.DB, accountID string, accessToken strin
143152 // Update the struct we have in memory
144153 existingSession.AccessToken = accessToken
145154 existingSession.Expires = expireTime
146- existingSession.IsOnline = true // Ensure it's marked online
155+ existingSession.IsActive = true // Ensure it's marked active
147156 logger.Info("Session updated successfully", "sessionID", existingSession.ID)
148157 return existingSession, nil
149158 } else {
150159 // If the session does not exist, create a new one
151160 logger.Debug("No existing session found, creating new session", "accountID", accountID)
152161 sessionID := uuid.NewString() // Use uuid directly
153- _, err := database.Exec(
154- NewSessionQueries().InsertSession,
155- sessionID, accountID, accessToken, browserInfo, expireTime, true,
162+ insertQuery := NewSessionQueries().InsertSession
163+ insertParams := []interface{}{sessionID, accountID, accessToken, browserInfo, expireTime, true}
164+ logger.Debug("Executing session insert", "query", insertQuery, "params", insertParams)
165+ result, err := database.Exec(
166+ insertQuery,
167+ insertParams...,
156168 )
169+ if err != nil {
170+ logger.Error("Database Exec failed during session insert", "error", err, "accountID", accountID)
171+ } else {
172+ rowsAffected, _ := result.RowsAffected()
173+ lastInsertId, _ := result.LastInsertId() // Might be 0/irrelevant for SQLite UUID PKs, but log anyway
174+ logger.Debug("Database Exec success during session insert", "accountID", accountID, "rows_affected", rowsAffected, "last_insert_id", lastInsertId)
175+ }
157176 if err != nil {
158177 logger.Error("Failed to create new session", "error", err, "accountID", accountID)
159178 return nil, fmt.Errorf("error creating session: %w", err)
@@ -165,7 +184,7 @@ func CreateOrUpdateSession(database *sql.DB, accountID string, accessToken strin
165184 AccessToken: accessToken,
166185 BrowserInfo: browserInfo,
167186 Expires: expireTime,
168- IsOnline: true,
187+ IsActive: true,
169188 }
170189 logger.Info("New session created successfully", "sessionID", newSession.ID)
171190 return newSession, nil
@@ -176,14 +195,14 @@ func CreateOrUpdateSession(database *sql.DB, accountID string, accessToken strin
176195 func GetSessionByAccountAndBrowser(database *sql.DB, accountID string, browserInfo string) (*db.Sessions, error) {
177196 session := &db.Sessions{}
178197 // Define the query inline or add to SessionQueries struct
179- query := "SELECT id, account_id, access_token, browser_info, expires, is_online FROM sessions WHERE account_id = ? AND browser_info = ?"
198+ query := "SELECT id, account_id, access_token, browser_info, expires, is_active FROM sessions WHERE account_id = ? AND browser_info = ?"
180199 err := database.QueryRow(query, accountID, browserInfo).Scan(
181200 &session.ID,
182201 &session.AccountID,
183202 &session.AccessToken,
184203 &session.BrowserInfo,
185204 &session.Expires,
186- &session.IsOnline,
205+ &session.IsActive,
187206 )
188207 if err != nil {
189208 // Return sql.ErrNoRows if not found
@@ -268,8 +287,8 @@ func CheckDBSessionExists(database *sql.DB, sessionID string) (bool, error) {
268287 return sessionExists, nil
269288 }
270289
271-// InvalidateDBSession marks a session as offline
272-func InvalidateDBSession(database *sql.DB, sessionID string) error {
290+// MarkSessionInactive marks a session as inactive (sets is_active to false)
291+func MarkSessionInactive(database *sql.DB, sessionID string) error {
273292 _, err := database.Exec(
274293 NewSessionQueries().InvalidateSession,
275294 false,
@@ -277,10 +296,31 @@ func InvalidateDBSession(database *sql.DB, sessionID string) error {
277296 )
278297
279298 if err != nil {
280- logger.Error("Failed to invalidate session", "error", err, "session_id", sessionID)
299+ logger.Error("Failed to mark session inactive", "error", err, "session_id", sessionID)
281300 return err
282301 }
283302
284- logger.Debug("Session invalidated", "sessionID", sessionID)
303+ logger.Debug("Session marked as inactive", "sessionID", sessionID)
304+ return nil
305+}
306+
307+// MarkSessionActive sets the is_active flag to true for a given session ID.
308+func MarkSessionActive(database *sql.DB, sessionID string) error {
309+ query := NewSessionQueries().MarkSessionActive
310+ result, err := database.Exec(query, sessionID)
311+
312+ if err != nil {
313+ logger.Error("Failed to mark session active in database", "error", err, "session_id", sessionID)
314+ return fmt.Errorf("failed to execute MarkSessionActive query: %w", err)
315+ }
316+
317+ rowsAffected, _ := result.RowsAffected()
318+ if rowsAffected == 0 {
319+ logger.Warn("MarkSessionActive query executed but did not affect any rows", "sessionID", sessionID)
320+ // This might indicate the session ID didn't exist, though the query technically succeeded.
321+ // Consider if this should return an error or not. For now, logging a warning.
322+ }
323+
324+ logger.Debug("Marked session active in database", "sessionID", sessionID, "rows_affected", rowsAffected)
285325 return nil
286326 }
@@ -20,19 +20,21 @@ type SessionQueries struct {
20 UpdateSessionExpiry string20 UpdateSessionExpiry string
21 CheckSessionExists string21 CheckSessionExists string
22 InvalidateSession string22 InvalidateSession string
23+ MarkSessionActive string
23 }24 }
24 25
25 // NewSessionQueries returns a new instance of SessionQueries26 // NewSessionQueries returns a new instance of SessionQueries
26 func NewSessionQueries() *SessionQueries {27 func NewSessionQueries() *SessionQueries {
27 return &SessionQueries{28 return &SessionQueries{
28- InsertSession: "INSERT INTO sessions (id, account_id, access_token, browser_info, expires, is_online) VALUES (?, ?, ?, ?, ?, ?)",29+ InsertSession: "INSERT INTO sessions (id, account_id, access_token, browser_info, expires, is_active) VALUES (?, ?, ?, ?, ?, ?)",
29- GetSessionByToken: "SELECT sessions.id, sessions.account_id, sessions.access_token, sessions.browser_info, sessions.expires, sessions.is_online FROM sessions INNER JOIN account ON sessions.account_id = account.id INNER JOIN provider ON account.id = provider.account_id WHERE sessions.access_token = ? AND sessions.browser_info = ?",30+ GetSessionByToken: "SELECT sessions.id, sessions.account_id, sessions.access_token, sessions.browser_info, sessions.expires, sessions.is_active FROM sessions INNER JOIN account ON sessions.account_id = account.id INNER JOIN provider ON account.id = provider.account_id WHERE sessions.access_token = ? AND sessions.browser_info = ?",
30- UpdateSessionToken: "UPDATE sessions SET access_token = ?, expires = ? WHERE id = ?",31+ UpdateSessionToken: "UPDATE sessions SET access_token = ?, expires = ?, is_active = true WHERE id = ?",
31 DeleteSession: "DELETE FROM sessions WHERE account_id = ? AND id = ?",32 DeleteSession: "DELETE FROM sessions WHERE account_id = ? AND id = ?",
32 GetSessionExpiry: "SELECT expires FROM sessions WHERE account_id = ? AND id = ?",33 GetSessionExpiry: "SELECT expires FROM sessions WHERE account_id = ? AND id = ?",
33 UpdateSessionExpiry: "UPDATE sessions SET expires = ? WHERE account_id = ? AND id = ?",34 UpdateSessionExpiry: "UPDATE sessions SET expires = ? WHERE account_id = ? AND id = ?",
34 CheckSessionExists: "SELECT EXISTS(SELECT 1 FROM sessions WHERE id = ?)",35 CheckSessionExists: "SELECT EXISTS(SELECT 1 FROM sessions WHERE id = ?)",
35- InvalidateSession: "UPDATE sessions SET is_online = ? WHERE id = ?",36+ InvalidateSession: "UPDATE sessions SET is_active = ? WHERE id = ?",
37+ MarkSessionActive: "UPDATE sessions SET is_active = true WHERE id = ?",
36 }38 }
37 }39 }
38 40
@@ -58,7 +60,7 @@ func CreateDBSession(database *sql.DB, browserInfo string, accessToken string, a
58 AccessToken: accessToken,60 AccessToken: accessToken,
59 BrowserInfo: browserInfo,61 BrowserInfo: browserInfo,
60 Expires: expireTime,62 Expires: expireTime,
61- IsOnline: true,63+ IsActive: true,
62 }64 }
63 65
64 logger.Debug("Session created in database", "session_id", sessionID, "account_id", accountID, "expires", expireTime)66 logger.Debug("Session created in database", "session_id", sessionID, "account_id", accountID, "expires", expireTime)
@@ -79,7 +81,7 @@ func GetDBUserSession(database *sql.DB, accessToken string, browserInfo string)
79 &session.AccessToken,81 &session.AccessToken,
80 &session.BrowserInfo,82 &session.BrowserInfo,
81 &session.Expires,83 &session.Expires,
82- &session.IsOnline,84+ &session.IsActive,
83 )85 )
84 86
85 if err != nil {87 if err != nil {
@@ -130,12 +132,19 @@ func CreateOrUpdateSession(database *sql.DB, accountID string, accessToken strin
130 // If the session exists, update its token and expiry132 // If the session exists, update its token and expiry
131 if existingSession != nil {133 if existingSession != nil {
132 logger.Debug("Existing session found, updating token and expiry", "sessionID", existingSession.ID, "accountID", accountID)134 logger.Debug("Existing session found, updating token and expiry", "sessionID", existingSession.ID, "accountID", accountID)
133- _, err := database.Exec(135+ updateQuery := NewSessionQueries().UpdateSessionToken
134- NewSessionQueries().UpdateSessionToken,136+ updateParams := []interface{}{accessToken, expireTime, existingSession.ID}
135- accessToken,137+ logger.Debug("Executing session update", "query", updateQuery, "params", updateParams)
136- expireTime,138+ result, err := database.Exec(
137- existingSession.ID,139+ updateQuery,
140+ updateParams...,
138 )141 )
142+ if err != nil {
143+ logger.Error("Database Exec failed during session update", "error", err, "session_id", existingSession.ID)
144+ } else {
145+ rowsAffected, _ := result.RowsAffected()
146+ logger.Debug("Database Exec success during session update", "session_id", existingSession.ID, "rows_affected", rowsAffected)
147+ }
139 if err != nil {148 if err != nil {
140 logger.Error("Failed to update existing session", "error", err, "session_id", existingSession.ID)149 logger.Error("Failed to update existing session", "error", err, "session_id", existingSession.ID)
141 return nil, fmt.Errorf("error updating session: %w", err)150 return nil, fmt.Errorf("error updating session: %w", err)
@@ -143,17 +152,27 @@ func CreateOrUpdateSession(database *sql.DB, accountID string, accessToken strin
143 // Update the struct we have in memory152 // Update the struct we have in memory
144 existingSession.AccessToken = accessToken153 existingSession.AccessToken = accessToken
145 existingSession.Expires = expireTime154 existingSession.Expires = expireTime
146- existingSession.IsOnline = true // Ensure it's marked online155+ existingSession.IsActive = true // Ensure it's marked active
147 logger.Info("Session updated successfully", "sessionID", existingSession.ID)156 logger.Info("Session updated successfully", "sessionID", existingSession.ID)
148 return existingSession, nil157 return existingSession, nil
149 } else {158 } else {
150 // If the session does not exist, create a new one159 // If the session does not exist, create a new one
151 logger.Debug("No existing session found, creating new session", "accountID", accountID)160 logger.Debug("No existing session found, creating new session", "accountID", accountID)
152 sessionID := uuid.NewString() // Use uuid directly161 sessionID := uuid.NewString() // Use uuid directly
153- _, err := database.Exec(162+ insertQuery := NewSessionQueries().InsertSession
154- NewSessionQueries().InsertSession,163+ insertParams := []interface{}{sessionID, accountID, accessToken, browserInfo, expireTime, true}
155- sessionID, accountID, accessToken, browserInfo, expireTime, true,164+ logger.Debug("Executing session insert", "query", insertQuery, "params", insertParams)
165+ result, err := database.Exec(
166+ insertQuery,
167+ insertParams...,
156 )168 )
169+ if err != nil {
170+ logger.Error("Database Exec failed during session insert", "error", err, "accountID", accountID)
171+ } else {
172+ rowsAffected, _ := result.RowsAffected()
173+ lastInsertId, _ := result.LastInsertId() // Might be 0/irrelevant for SQLite UUID PKs, but log anyway
174+ logger.Debug("Database Exec success during session insert", "accountID", accountID, "rows_affected", rowsAffected, "last_insert_id", lastInsertId)
175+ }
157 if err != nil {176 if err != nil {
158 logger.Error("Failed to create new session", "error", err, "accountID", accountID)177 logger.Error("Failed to create new session", "error", err, "accountID", accountID)
159 return nil, fmt.Errorf("error creating session: %w", err)178 return nil, fmt.Errorf("error creating session: %w", err)
@@ -165,7 +184,7 @@ func CreateOrUpdateSession(database *sql.DB, accountID string, accessToken strin
165 AccessToken: accessToken,184 AccessToken: accessToken,
166 BrowserInfo: browserInfo,185 BrowserInfo: browserInfo,
167 Expires: expireTime,186 Expires: expireTime,
168- IsOnline: true,187+ IsActive: true,
169 }188 }
170 logger.Info("New session created successfully", "sessionID", newSession.ID)189 logger.Info("New session created successfully", "sessionID", newSession.ID)
171 return newSession, nil190 return newSession, nil
@@ -176,14 +195,14 @@ func CreateOrUpdateSession(database *sql.DB, accountID string, accessToken strin
176 func GetSessionByAccountAndBrowser(database *sql.DB, accountID string, browserInfo string) (*db.Sessions, error) {195 func GetSessionByAccountAndBrowser(database *sql.DB, accountID string, browserInfo string) (*db.Sessions, error) {
177 session := &db.Sessions{}196 session := &db.Sessions{}
178 // Define the query inline or add to SessionQueries struct197 // Define the query inline or add to SessionQueries struct
179- query := "SELECT id, account_id, access_token, browser_info, expires, is_online FROM sessions WHERE account_id = ? AND browser_info = ?"198+ query := "SELECT id, account_id, access_token, browser_info, expires, is_active FROM sessions WHERE account_id = ? AND browser_info = ?"
180 err := database.QueryRow(query, accountID, browserInfo).Scan(199 err := database.QueryRow(query, accountID, browserInfo).Scan(
181 &session.ID,200 &session.ID,
182 &session.AccountID,201 &session.AccountID,
183 &session.AccessToken,202 &session.AccessToken,
184 &session.BrowserInfo,203 &session.BrowserInfo,
185 &session.Expires,204 &session.Expires,
186- &session.IsOnline,205+ &session.IsActive,
187 )206 )
188 if err != nil {207 if err != nil {
189 // Return sql.ErrNoRows if not found208 // Return sql.ErrNoRows if not found
@@ -268,8 +287,8 @@ func CheckDBSessionExists(database *sql.DB, sessionID string) (bool, error) {
268 return sessionExists, nil287 return sessionExists, nil
269 }288 }
270 289
271-// InvalidateDBSession marks a session as offline290+// MarkSessionInactive marks a session as inactive (sets is_active to false)
272-func InvalidateDBSession(database *sql.DB, sessionID string) error {291+func MarkSessionInactive(database *sql.DB, sessionID string) error {
273 _, err := database.Exec(292 _, err := database.Exec(
274 NewSessionQueries().InvalidateSession,293 NewSessionQueries().InvalidateSession,
275 false,294 false,
@@ -277,10 +296,31 @@ func InvalidateDBSession(database *sql.DB, sessionID string) error {
277 )296 )
278 297
279 if err != nil {298 if err != nil {
280- logger.Error("Failed to invalidate session", "error", err, "session_id", sessionID)299+ logger.Error("Failed to mark session inactive", "error", err, "session_id", sessionID)
281 return err300 return err
282 }301 }
283 302
284- logger.Debug("Session invalidated", "sessionID", sessionID)303+ logger.Debug("Session marked as inactive", "sessionID", sessionID)
304+ return nil
305+}
306+
307+// MarkSessionActive sets the is_active flag to true for a given session ID.
308+func MarkSessionActive(database *sql.DB, sessionID string) error {
309+ query := NewSessionQueries().MarkSessionActive
310+ result, err := database.Exec(query, sessionID)
311+
312+ if err != nil {
313+ logger.Error("Failed to mark session active in database", "error", err, "session_id", sessionID)
314+ return fmt.Errorf("failed to execute MarkSessionActive query: %w", err)
315+ }
316+
317+ rowsAffected, _ := result.RowsAffected()
318+ if rowsAffected == 0 {
319+ logger.Warn("MarkSessionActive query executed but did not affect any rows", "sessionID", sessionID)
320+ // This might indicate the session ID didn't exist, though the query technically succeeded.
321+ // Consider if this should return an error or not. For now, logging a warning.
322+ }
323+
324+ logger.Debug("Marked session active in database", "sessionID", sessionID, "rows_affected", rowsAffected)
285 return nil325 return nil
286 }326 }
M internal/httpserve/handlers/admin_view.go +67 -32
@@ -3,64 +3,99 @@ package handlers
33 import (
44 "fmt"
55
6- "net/http"
7-
86 "github.com/bnema/gordon/internal/server"
7+ "github.com/bnema/gordon/internal/templating/models/templ/pages/admin"
98 "github.com/bnema/gordon/internal/templating/render"
9+ "github.com/bnema/gordon/pkg/docker"
1010 "github.com/labstack/echo/v4"
1111 )
1212
13-var (
14- mainPath = "html/admin"
15- fragmentsPath = "html/fragments"
16-)
17-
1813 // GetLocalizedData returns the data for the localization
19-func GetLocalizedData(c echo.Context, a *server.App) (map[string]interface{}, error) {
14+func GetLocalizedData(c echo.Context, a *server.App) (map[string]string, error) {
2015 lang := c.Get("LangKey")
2116 if lang == nil {
2217 return nil, fmt.Errorf("LangKey not found in context")
2318 }
2419
25- yamlData, err := render.GetLocalization(lang.(string), a)
20+ langStr, ok := lang.(string)
21+ if !ok {
22+ return nil, fmt.Errorf("LangKey is not a string")
23+ }
24+
25+ yamlDataInterface, err := render.GetLocalization(langStr, a)
2626 if err != nil {
2727 return nil, fmt.Errorf("failed to get localization: %w", err)
2828 }
29- return map[string]interface{}{
30- "Lang": yamlData,
31- "LogoutURL": a.Config.Admin.Path + "/logout",
32- "AdminPath": a.Config.Admin.Path,
33- }, nil
34-}
3529
36-// renderAdminPage is a generalized function to render admin pages
37-func renderAdminPage(c echo.Context, a *server.App, templateName string) error {
38- data, err := GetLocalizedData(c, a)
39- if err != nil {
40- return err
30+ // Convert map[string]interface{} to map[string]string
31+ langContent := make(map[string]string)
32+ for key, value := range yamlDataInterface {
33+ strValue, ok := value.(string)
34+ if !ok {
35+ // Handle cases where the value is not a string, e.g., log a warning or skip
36+ // For now, we'll skip non-string values
37+ continue
38+ }
39+ langContent[key] = strValue
4140 }
4241
43- data["AdminPath"] = a.Config.Admin.Path
42+ // Return map[string]string directly
43+ return langContent, nil
44+}
4445
45- rendererData, err := render.GetHTMLRenderer(mainPath, templateName, a.TemplateFS, a, fragmentsPath)
46+// AdminRoute is the route for the admin panel
47+func AdminRoute(c echo.Context, a *server.App) error {
48+ langContent, err := GetLocalizedData(c, a)
4649 if err != nil {
47- return err
50+ return sendError(c, err) // Assuming sendError exists
4851 }
4952
50- renderedHTML, err := rendererData.Render(data, a)
51- if err != nil {
52- return err
53+ data := admin.IndexPageData{
54+ Title: "Admin Dashboard", // Or get from LangContent if available
55+ BuildVersion: a.Config.Build.BuildVersion,
56+ AdminPath: a.Config.Admin.Path,
57+ UserSettings: a.Config.Admin.Path + "/user", // Assuming this path
58+ LogoutURL: a.Config.Admin.Path + "/logout",
59+ LangContent: langContent,
5360 }
5461
55- return c.HTML(http.StatusOK, renderedHTML)
56-}
62+ // Use the Templ renderer
63+ renderer := render.NewTemplRenderer(a)
64+ component := admin.IndexPage(data)
5765
58-// AdminRoute is the route for the admin panel
59-func AdminRoute(c echo.Context, a *server.App) error {
60- return renderAdminPage(c, a, "index.gohtml")
66+ return renderer.RenderTempl(c, component)
6167 }
6268
6369 // AdminManagerRoute is the route of the manager page
6470 func AdminManagerRoute(c echo.Context, a *server.App) error {
65- return renderAdminPage(c, a, "manager.gohtml")
71+ // Fetch container and image counts
72+ containers, err := docker.ListRunningContainers()
73+ if err != nil {
74+ return sendError(c, fmt.Errorf("failed to list containers: %w", err))
75+ }
76+ images, err := docker.ListContainerImages()
77+ if err != nil {
78+ return sendError(c, fmt.Errorf("failed to list images: %w", err))
79+ }
80+
81+ containerCount := len(containers)
82+ imageCount := len(images)
83+
84+ title := "Container Manager" // Set a default title
85+ userSettingsURL := a.Config.Admin.Path + "/user" // Assuming this path
86+ logoutURL := a.Config.Admin.Path + "/logout"
87+
88+ // Use the Templ renderer
89+ renderer := render.NewTemplRenderer(a)
90+ component := admin.ManagerPage(
91+ title,
92+ a.Config.Build.BuildVersion,
93+ a.Config.Admin.Path,
94+ userSettingsURL,
95+ logoutURL,
96+ containerCount,
97+ imageCount,
98+ )
99+
100+ return renderer.RenderTempl(c, component)
66101 }
@@ -3,64 +3,99 @@ package handlers
3 import (3 import (
4 "fmt"4 "fmt"
5 5
6- "net/http"
7-
8 "github.com/bnema/gordon/internal/server"6 "github.com/bnema/gordon/internal/server"
7+ "github.com/bnema/gordon/internal/templating/models/templ/pages/admin"
9 "github.com/bnema/gordon/internal/templating/render"8 "github.com/bnema/gordon/internal/templating/render"
9+ "github.com/bnema/gordon/pkg/docker"
10 "github.com/labstack/echo/v4"10 "github.com/labstack/echo/v4"
11 )11 )
12 12
13-var (
14- mainPath = "html/admin"
15- fragmentsPath = "html/fragments"
16-)
17-
18 // GetLocalizedData returns the data for the localization13 // GetLocalizedData returns the data for the localization
19-func GetLocalizedData(c echo.Context, a *server.App) (map[string]interface{}, error) {14+func GetLocalizedData(c echo.Context, a *server.App) (map[string]string, error) {
20 lang := c.Get("LangKey")15 lang := c.Get("LangKey")
21 if lang == nil {16 if lang == nil {
22 return nil, fmt.Errorf("LangKey not found in context")17 return nil, fmt.Errorf("LangKey not found in context")
23 }18 }
24 19
25- yamlData, err := render.GetLocalization(lang.(string), a)20+ langStr, ok := lang.(string)
21+ if !ok {
22+ return nil, fmt.Errorf("LangKey is not a string")
23+ }
24+
25+ yamlDataInterface, err := render.GetLocalization(langStr, a)
26 if err != nil {26 if err != nil {
27 return nil, fmt.Errorf("failed to get localization: %w", err)27 return nil, fmt.Errorf("failed to get localization: %w", err)
28 }28 }
29- return map[string]interface{}{
30- "Lang": yamlData,
31- "LogoutURL": a.Config.Admin.Path + "/logout",
32- "AdminPath": a.Config.Admin.Path,
33- }, nil
34-}
35 29
36-// renderAdminPage is a generalized function to render admin pages30+ // Convert map[string]interface{} to map[string]string
37-func renderAdminPage(c echo.Context, a *server.App, templateName string) error {31+ langContent := make(map[string]string)
38- data, err := GetLocalizedData(c, a)32+ for key, value := range yamlDataInterface {
39- if err != nil {33+ strValue, ok := value.(string)
40- return err34+ if !ok {
35+ // Handle cases where the value is not a string, e.g., log a warning or skip
36+ // For now, we'll skip non-string values
37+ continue
38+ }
39+ langContent[key] = strValue
41 }40 }
42 41
43- data["AdminPath"] = a.Config.Admin.Path42+ // Return map[string]string directly
43+ return langContent, nil
44+}
44 45
45- rendererData, err := render.GetHTMLRenderer(mainPath, templateName, a.TemplateFS, a, fragmentsPath)46+// AdminRoute is the route for the admin panel
47+func AdminRoute(c echo.Context, a *server.App) error {
48+ langContent, err := GetLocalizedData(c, a)
46 if err != nil {49 if err != nil {
47- return err50+ return sendError(c, err) // Assuming sendError exists
48 }51 }
49 52
50- renderedHTML, err := rendererData.Render(data, a)53+ data := admin.IndexPageData{
51- if err != nil {54+ Title: "Admin Dashboard", // Or get from LangContent if available
52- return err55+ BuildVersion: a.Config.Build.BuildVersion,
56+ AdminPath: a.Config.Admin.Path,
57+ UserSettings: a.Config.Admin.Path + "/user", // Assuming this path
58+ LogoutURL: a.Config.Admin.Path + "/logout",
59+ LangContent: langContent,
53 }60 }
54 61
55- return c.HTML(http.StatusOK, renderedHTML)62+ // Use the Templ renderer
56-}63+ renderer := render.NewTemplRenderer(a)
64+ component := admin.IndexPage(data)
57 65
58-// AdminRoute is the route for the admin panel66+ return renderer.RenderTempl(c, component)
59-func AdminRoute(c echo.Context, a *server.App) error {
60- return renderAdminPage(c, a, "index.gohtml")
61 }67 }
62 68
63 // AdminManagerRoute is the route of the manager page69 // AdminManagerRoute is the route of the manager page
64 func AdminManagerRoute(c echo.Context, a *server.App) error {70 func AdminManagerRoute(c echo.Context, a *server.App) error {
65- return renderAdminPage(c, a, "manager.gohtml")71+ // Fetch container and image counts
72+ containers, err := docker.ListRunningContainers()
73+ if err != nil {
74+ return sendError(c, fmt.Errorf("failed to list containers: %w", err))
75+ }
76+ images, err := docker.ListContainerImages()
77+ if err != nil {
78+ return sendError(c, fmt.Errorf("failed to list images: %w", err))
79+ }
80+
81+ containerCount := len(containers)
82+ imageCount := len(images)
83+
84+ title := "Container Manager" // Set a default title
85+ userSettingsURL := a.Config.Admin.Path + "/user" // Assuming this path
86+ logoutURL := a.Config.Admin.Path + "/logout"
87+
88+ // Use the Templ renderer
89+ renderer := render.NewTemplRenderer(a)
90+ component := admin.ManagerPage(
91+ title,
92+ a.Config.Build.BuildVersion,
93+ a.Config.Admin.Path,
94+ userSettingsURL,
95+ logoutURL,
96+ containerCount,
97+ imageCount,
98+ )
99+
100+ return renderer.RenderTempl(c, component)
66 }101 }
M internal/httpserve/handlers/container_management.go +36 -128
@@ -9,6 +9,7 @@ import (
99
1010 "github.com/bnema/gordon/internal/server"
1111 "github.com/bnema/gordon/internal/templating/load"
12+ components "github.com/bnema/gordon/internal/templating/models/templ/components"
1213 "github.com/bnema/gordon/internal/templating/render"
1314 "github.com/bnema/gordon/pkg/docker"
1415 "github.com/bnema/gordon/pkg/humanize"
@@ -88,35 +89,6 @@ type ContainerDisplay struct {
8889 IsGordonContainer bool
8990 }
9091
91-// renderHTML is a generalized function to render HTML
92-func renderHTML(c echo.Context, a *server.App, path, templateName string, data map[string]interface{}) error {
93- logger.Debug("Rendering HTML", "path", path, "template", templateName, "data_keys", getKeysFromMap(data))
94-
95- rendererData, err := render.GetHTMLRenderer(path, templateName, a.TemplateFS, a)
96- if err != nil {
97- logger.Error("Error getting HTML renderer", "error", err)
98- return err
99- }
100-
101- renderedHTML, err := rendererData.Render(data, a)
102- if err != nil {
103- logger.Error("Error rendering HTML", "error", err)
104- return err
105- }
106-
107- logger.Debug("HTML rendered successfully", "content_length", len(renderedHTML))
108- return c.HTML(http.StatusOK, renderedHTML)
109-}
110-
111-// getKeysFromMap returns a slice of keys from a map
112-func getKeysFromMap(data map[string]interface{}) []string {
113- keys := make([]string, 0, len(data))
114- for k := range data {
115- keys = append(keys, k)
116- }
117- return keys
118-}
119-
12092 // ActionSuccess returns the success HTML fragment
12193 func ActionSuccess(a *server.App) string {
12294 // Create a data structure to pass to the template
@@ -135,60 +107,54 @@ func ActionSuccess(a *server.App) string {
135107
136108 // ImageManagerComponent handles the /image-manager route (HTMX route)
137109 func ImageManagerComponent(c echo.Context, a *server.App) error {
138- logger.Debug("ImageManagerComponent called", "url", c.Request().URL.String())
139-
140110 images, err := docker.ListContainerImages()
141111 if err != nil {
142- logger.Error("Error listing container images:", "error", err)
143112 return sendError(c, err)
144113 }
145114
146- logger.Debug("Images found", "count", len(images))
147- for i, img := range images {
148- logger.Debug("Image details", "index", i, "id", img.ID, "repoTags", img.RepoTags)
149- }
150-
151- var humanReadableImages []HumanReadableContainerImage
152-
115+ var humanReadableImages []components.HumanReadableImage
153116 for _, img := range images {
154- shortID := img.ID[7:19]
117+ shortID := img.ID[7:19] // Display a shorter version of the ID
155118 safelyInteractWithIDMap(Update, shortID, img.ID)
156- createdTime := time.Unix(img.Created, 0)
157- createdStr := humanize.TimeAgo(createdTime)
119+ createdStr := humanize.TimeAgo(time.Unix(img.Created, 0))
158120 sizeStr := humanize.BytesToReadableSize(img.Size)
159121
160- for _, repoTag := range img.RepoTags {
161- // Split the repo tag into repository and tag
122+ // Parse RepoTags to extract repository and tag
123+ var repository, tag string
124+ repoTag := "<none>:<none>"
125+ if len(img.RepoTags) > 0 {
126+ repoTag = img.RepoTags[0]
162127 parts := strings.Split(repoTag, ":")
163- repository := parts[0]
164- tag := "latest"
165- if len(parts) > 1 {
128+ if len(parts) == 2 {
129+ repository = parts[0]
166130 tag = parts[1]
131+ } else {
132+ repository = repoTag // Handle cases like "image" without a tag
133+ tag = "latest"
167134 }
168-
169- humanReadableImages = append(humanReadableImages, HumanReadableContainerImage{
170- ID: img.ID,
171- ShortID: shortID,
172- CreatedStr: createdStr,
173- SizeStr: sizeStr,
174- Name: repoTag,
175- RepoDigests: img.RepoDigests,
176- RepoTags: img.RepoTags,
177- // Add fields that match template expectations
178- Repository: repository,
179- Tag: tag,
180- Size: sizeStr,
181- Created: createdStr,
182- })
183135 }
184- }
185136
186- data := map[string]interface{}{
187- "Images": humanReadableImages,
137+ // Create instance of components.HumanReadableImage
138+ humanReadableImages = append(humanReadableImages, components.HumanReadableImage{
139+ ID: img.ID,
140+ ShortID: shortID,
141+ Created: createdStr,
142+ Size: sizeStr,
143+ Name: repoTag,
144+ RepoDigests: img.RepoDigests,
145+ RepoTags: img.RepoTags,
146+ Repository: repository,
147+ Tag: tag,
148+ })
188149 }
189150
190151 logger.Debug("Rendering imagelist template with", len(humanReadableImages), "images")
191- return renderHTML(c, a, "html/fragments", "imagelist.gohtml", data)
152+
153+ // Use the Templ renderer
154+ renderer := render.NewTemplRenderer(a)
155+ component := components.ImageList(humanReadableImages)
156+
157+ return renderer.RenderTempl(c, component)
192158 }
193159
194160 // ImageManagerDelete handles the /image-manager/delete route
@@ -226,69 +192,11 @@ func ContainerManagerComponent(c echo.Context, a *server.App) error {
226192 logger.Debug("Container details", "index", i, "id", container.ID, "names", container.Names, "state", container.State)
227193 }
228194
229- var humanReadableContainers []HumanReadableContainer
230-
231- for _, container := range containers {
232- localContainer := container // Make a local copy
233- sizeStr := humanize.BytesToReadableSize(container.SizeRw)
234- stateColor := "blue"
235-
236- if container.State == "running" {
237- stateColor = "green"
238- } else if container.State == "exited" {
239- stateColor = "red"
240- }
241- var ports []string
242- for _, port := range container.Ports {
243- portStr := fmt.Sprintf("%d:%d", port.PublicPort, port.PrivatePort)
244- ports = append(ports, portStr)
245- }
246-
247- for _, name := range container.Names {
248- name = name[1:]
249- humanReadableContainers = append(humanReadableContainers, HumanReadableContainer{
250- Container: &localContainer,
251- SizeStr: sizeStr,
252- UpSince: humanize.TimeAgo(time.Unix(container.Created, 0)),
253- StateColor: stateColor,
254- Name: name,
255- Ports: ports,
256- ProxyPort: extractProxyPort(container.Labels),
257- CreatedStr: humanize.TimeAgo(time.Unix(container.Created, 0)),
258- })
259- }
260- }
261- yamlData, err := GetLocalizedData(c, a)
262- if err != nil {
263- return err
264- }
265- data := map[string]interface{}{
266- "Lang": yamlData["Lang"],
267- "Containers": humanReadableContainers,
268- "NoContainer": len(humanReadableContainers) == 0,
269- }
270-
271- logger.Debug("Rendering containerlist template",
272- "humanReadableContainers", len(humanReadableContainers),
273- "noContainer", len(humanReadableContainers) == 0)
274-
275- return renderHTML(c, a, "html/fragments", "containerlist.gohtml", data)
276-}
277-
278-func extractProxyPort(labels map[string]string) string {
279- // Check for the gordon proxy port label
280- if port, ok := labels["gordon.proxy.port"]; ok {
281- return port
282- }
283-
284- // Backward compatibility for Traefik-labeled containers
285- for key, value := range labels {
286- if strings.Contains(key, "loadbalancer.server.port") {
287- return value
288- }
289- }
195+ // Use the Templ renderer
196+ renderer := render.NewTemplRenderer(a)
197+ component := components.ContainerList(containers, len(containers) == 0)
290198
291- return ""
199+ return renderer.RenderTempl(c, component)
292200 }
293201
294202 // ContainerManagerDelete handles the /container-manager/delete route
@@ -9,6 +9,7 @@ import (
9 9
10 "github.com/bnema/gordon/internal/server"10 "github.com/bnema/gordon/internal/server"
11 "github.com/bnema/gordon/internal/templating/load"11 "github.com/bnema/gordon/internal/templating/load"
12+ components "github.com/bnema/gordon/internal/templating/models/templ/components"
12 "github.com/bnema/gordon/internal/templating/render"13 "github.com/bnema/gordon/internal/templating/render"
13 "github.com/bnema/gordon/pkg/docker"14 "github.com/bnema/gordon/pkg/docker"
14 "github.com/bnema/gordon/pkg/humanize"15 "github.com/bnema/gordon/pkg/humanize"
@@ -88,35 +89,6 @@ type ContainerDisplay struct {
88 IsGordonContainer bool89 IsGordonContainer bool
89 }90 }
90 91
91-// renderHTML is a generalized function to render HTML
92-func renderHTML(c echo.Context, a *server.App, path, templateName string, data map[string]interface{}) error {
93- logger.Debug("Rendering HTML", "path", path, "template", templateName, "data_keys", getKeysFromMap(data))
94-
95- rendererData, err := render.GetHTMLRenderer(path, templateName, a.TemplateFS, a)
96- if err != nil {
97- logger.Error("Error getting HTML renderer", "error", err)
98- return err
99- }
100-
101- renderedHTML, err := rendererData.Render(data, a)
102- if err != nil {
103- logger.Error("Error rendering HTML", "error", err)
104- return err
105- }
106-
107- logger.Debug("HTML rendered successfully", "content_length", len(renderedHTML))
108- return c.HTML(http.StatusOK, renderedHTML)
109-}
110-
111-// getKeysFromMap returns a slice of keys from a map
112-func getKeysFromMap(data map[string]interface{}) []string {
113- keys := make([]string, 0, len(data))
114- for k := range data {
115- keys = append(keys, k)
116- }
117- return keys
118-}
119-
120 // ActionSuccess returns the success HTML fragment92 // ActionSuccess returns the success HTML fragment
121 func ActionSuccess(a *server.App) string {93 func ActionSuccess(a *server.App) string {
122 // Create a data structure to pass to the template94 // Create a data structure to pass to the template
@@ -135,60 +107,54 @@ func ActionSuccess(a *server.App) string {
135 107
136 // ImageManagerComponent handles the /image-manager route (HTMX route)108 // ImageManagerComponent handles the /image-manager route (HTMX route)
137 func ImageManagerComponent(c echo.Context, a *server.App) error {109 func ImageManagerComponent(c echo.Context, a *server.App) error {
138- logger.Debug("ImageManagerComponent called", "url", c.Request().URL.String())
139-
140 images, err := docker.ListContainerImages()110 images, err := docker.ListContainerImages()
141 if err != nil {111 if err != nil {
142- logger.Error("Error listing container images:", "error", err)
143 return sendError(c, err)112 return sendError(c, err)
144 }113 }
145 114
146- logger.Debug("Images found", "count", len(images))115+ var humanReadableImages []components.HumanReadableImage
147- for i, img := range images {
148- logger.Debug("Image details", "index", i, "id", img.ID, "repoTags", img.RepoTags)
149- }
150-
151- var humanReadableImages []HumanReadableContainerImage
152-
153 for _, img := range images {116 for _, img := range images {
154- shortID := img.ID[7:19]117+ shortID := img.ID[7:19] // Display a shorter version of the ID
155 safelyInteractWithIDMap(Update, shortID, img.ID)118 safelyInteractWithIDMap(Update, shortID, img.ID)
156- createdTime := time.Unix(img.Created, 0)119+ createdStr := humanize.TimeAgo(time.Unix(img.Created, 0))
157- createdStr := humanize.TimeAgo(createdTime)
158 sizeStr := humanize.BytesToReadableSize(img.Size)120 sizeStr := humanize.BytesToReadableSize(img.Size)
159 121
160- for _, repoTag := range img.RepoTags {122+ // Parse RepoTags to extract repository and tag
161- // Split the repo tag into repository and tag123+ var repository, tag string
124+ repoTag := "<none>:<none>"
125+ if len(img.RepoTags) > 0 {
126+ repoTag = img.RepoTags[0]
162 parts := strings.Split(repoTag, ":")127 parts := strings.Split(repoTag, ":")
163- repository := parts[0]128+ if len(parts) == 2 {
164- tag := "latest"129+ repository = parts[0]
165- if len(parts) > 1 {
166 tag = parts[1]130 tag = parts[1]
131+ } else {
132+ repository = repoTag // Handle cases like "image" without a tag
133+ tag = "latest"
167 }134 }
168-
169- humanReadableImages = append(humanReadableImages, HumanReadableContainerImage{
170- ID: img.ID,
171- ShortID: shortID,
172- CreatedStr: createdStr,
173- SizeStr: sizeStr,
174- Name: repoTag,
175- RepoDigests: img.RepoDigests,
176- RepoTags: img.RepoTags,
177- // Add fields that match template expectations
178- Repository: repository,
179- Tag: tag,
180- Size: sizeStr,
181- Created: createdStr,
182- })
183 }135 }
184- }
185 136
186- data := map[string]interface{}{137+ // Create instance of components.HumanReadableImage
187- "Images": humanReadableImages,138+ humanReadableImages = append(humanReadableImages, components.HumanReadableImage{
139+ ID: img.ID,
140+ ShortID: shortID,
141+ Created: createdStr,
142+ Size: sizeStr,
143+ Name: repoTag,
144+ RepoDigests: img.RepoDigests,
145+ RepoTags: img.RepoTags,
146+ Repository: repository,
147+ Tag: tag,
148+ })
188 }149 }
189 150
190 logger.Debug("Rendering imagelist template with", len(humanReadableImages), "images")151 logger.Debug("Rendering imagelist template with", len(humanReadableImages), "images")
191- return renderHTML(c, a, "html/fragments", "imagelist.gohtml", data)152+
153+ // Use the Templ renderer
154+ renderer := render.NewTemplRenderer(a)
155+ component := components.ImageList(humanReadableImages)
156+
157+ return renderer.RenderTempl(c, component)
192 }158 }
193 159
194 // ImageManagerDelete handles the /image-manager/delete route160 // ImageManagerDelete handles the /image-manager/delete route
@@ -226,69 +192,11 @@ func ContainerManagerComponent(c echo.Context, a *server.App) error {
226 logger.Debug("Container details", "index", i, "id", container.ID, "names", container.Names, "state", container.State)192 logger.Debug("Container details", "index", i, "id", container.ID, "names", container.Names, "state", container.State)
227 }193 }
228 194
229- var humanReadableContainers []HumanReadableContainer195+ // Use the Templ renderer
230-196+ renderer := render.NewTemplRenderer(a)
231- for _, container := range containers {197+ component := components.ContainerList(containers, len(containers) == 0)
232- localContainer := container // Make a local copy
233- sizeStr := humanize.BytesToReadableSize(container.SizeRw)
234- stateColor := "blue"
235-
236- if container.State == "running" {
237- stateColor = "green"
238- } else if container.State == "exited" {
239- stateColor = "red"
240- }
241- var ports []string
242- for _, port := range container.Ports {
243- portStr := fmt.Sprintf("%d:%d", port.PublicPort, port.PrivatePort)
244- ports = append(ports, portStr)
245- }
246-
247- for _, name := range container.Names {
248- name = name[1:]
249- humanReadableContainers = append(humanReadableContainers, HumanReadableContainer{
250- Container: &localContainer,
251- SizeStr: sizeStr,
252- UpSince: humanize.TimeAgo(time.Unix(container.Created, 0)),
253- StateColor: stateColor,
254- Name: name,
255- Ports: ports,
256- ProxyPort: extractProxyPort(container.Labels),
257- CreatedStr: humanize.TimeAgo(time.Unix(container.Created, 0)),
258- })
259- }
260- }
261- yamlData, err := GetLocalizedData(c, a)
262- if err != nil {
263- return err
264- }
265- data := map[string]interface{}{
266- "Lang": yamlData["Lang"],
267- "Containers": humanReadableContainers,
268- "NoContainer": len(humanReadableContainers) == 0,
269- }
270-
271- logger.Debug("Rendering containerlist template",
272- "humanReadableContainers", len(humanReadableContainers),
273- "noContainer", len(humanReadableContainers) == 0)
274-
275- return renderHTML(c, a, "html/fragments", "containerlist.gohtml", data)
276-}
277-
278-func extractProxyPort(labels map[string]string) string {
279- // Check for the gordon proxy port label
280- if port, ok := labels["gordon.proxy.port"]; ok {
281- return port
282- }
283-
284- // Backward compatibility for Traefik-labeled containers
285- for key, value := range labels {
286- if strings.Contains(key, "loadbalancer.server.port") {
287- return value
288- }
289- }
290 198
291- return ""199+ return renderer.RenderTempl(c, component)
292 }200 }
293 201
294 // ContainerManagerDelete handles the /container-manager/delete route202 // ContainerManagerDelete handles the /container-manager/delete route
M internal/httpserve/handlers/container_transactions.go +22 -42
@@ -6,6 +6,7 @@ import (
66
77 "github.com/bnema/gordon/internal/server"
88 "github.com/bnema/gordon/internal/templating/cmdparams"
9+ "github.com/bnema/gordon/internal/templating/models/templ/components"
910 "github.com/bnema/gordon/internal/templating/render"
1011 "github.com/bnema/gordon/pkg/docker"
1112 "github.com/bnema/gordon/pkg/logger"
@@ -37,41 +38,35 @@ func generateOrderedYAML(infoMap map[string]interface{}) (string, error) {
3738 // ContainerManagerEditGET displays the edit container view
3839 func ContainerManagerEditGET(c echo.Context, a *server.App) error {
3940 containerID := c.Param("ID")
40-
41- // Get the container info
4241 containerInfo, err := docker.GetContainerInfo(containerID)
4342 if err != nil {
4443 return sendError(c, err)
4544 }
4645
47- // Trim the slash from the container name
48- containerInfo.Name = strings.TrimPrefix(containerInfo.Name, "/")
49- // Get the network names
50- networkNames := make([]string, 0, len(containerInfo.NetworkSettings.Networks))
51- for networkName := range containerInfo.NetworkSettings.Networks {
52- networkNames = append(networkNames, networkName)
53- }
54-
55- // Prepare Ports
56- portMappings := make([]string, 0)
57- for port, bindings := range containerInfo.HostConfig.PortBindings {
58- for _, binding := range bindings {
59- portMappings = append(portMappings, fmt.Sprintf("%s:%s/%s", binding.HostPort, port.Port(), port.Proto()))
46+ portMappings := make(map[string]string)
47+ for internalPort, hostBindings := range containerInfo.HostConfig.PortBindings {
48+ if len(hostBindings) > 0 {
49+ hostBinding := hostBindings[0]
50+ portMappings[string(internalPort)] = fmt.Sprintf("%s:%s", hostBinding.HostIP, hostBinding.HostPort)
6051 }
6152 }
6253
63- // Prepare Volumes (Mounts)
64- volumeMappings := make([]string, 0)
54+ volumeMappings := make(map[string]string)
6555 for _, mount := range containerInfo.Mounts {
66- volumeMappings = append(volumeMappings, fmt.Sprintf("%s:%s", mount.Source, mount.Destination))
56+ volumeMappings[mount.Source] = mount.Destination
6757 }
6858
69- // Initialize a map to store the information from the container
70- infoMap := make(map[string]interface{})
59+ networkNames := []string{}
60+ for name := range containerInfo.NetworkSettings.Networks {
61+ networkNames = append(networkNames, name)
62+ }
7163
72- // Populate the map with container details
73- infoMap["Name"] = containerInfo.Name
74- infoMap["Image"] = containerInfo.Config.Image
64+ infoMap := map[string]interface{}{
65+ "Name": strings.TrimPrefix(containerInfo.Name, "/"),
66+ "Image": containerInfo.Config.Image,
67+ "CMD": containerInfo.Config.Cmd,
68+ "Entrypoint": containerInfo.Config.Entrypoint,
69+ }
7570 infoMap["Hostname"] = containerInfo.Config.Hostname
7671 infoMap["Ports"] = portMappings
7772 infoMap["Volumes"] = volumeMappings
@@ -86,26 +81,11 @@ func ContainerManagerEditGET(c echo.Context, a *server.App) error {
8681 return sendError(c, err)
8782 }
8883
89- // Pass the containerInfo to the data map
90- data := map[string]interface{}{
91- "Title": "Edit container",
92- "ID": containerID,
93- "ContainerInfo": yamlString,
94- }
95-
96- rendererData, err := render.GetHTMLRenderer("html/fragments", "editcontainer.gohtml", a.TemplateFS, a)
97-
98- if err != nil {
99- return sendError(c, err)
100- }
101-
102- renderedHTML, err := rendererData.Render(data, a)
103-
104- if err != nil {
105- return sendError(c, err)
106- }
84+ // Use the Templ renderer
85+ renderer := render.NewTemplRenderer(a)
86+ component := components.EditContainer(containerID, yamlString)
10787
108- return c.HTML(200, renderedHTML)
88+ return renderer.RenderTempl(c, component)
10989 }
11090
11191 type TransactionStep func() error
@@ -6,6 +6,7 @@ import (
6 6
7 "github.com/bnema/gordon/internal/server"7 "github.com/bnema/gordon/internal/server"
8 "github.com/bnema/gordon/internal/templating/cmdparams"8 "github.com/bnema/gordon/internal/templating/cmdparams"
9+ "github.com/bnema/gordon/internal/templating/models/templ/components"
9 "github.com/bnema/gordon/internal/templating/render"10 "github.com/bnema/gordon/internal/templating/render"
10 "github.com/bnema/gordon/pkg/docker"11 "github.com/bnema/gordon/pkg/docker"
11 "github.com/bnema/gordon/pkg/logger"12 "github.com/bnema/gordon/pkg/logger"
@@ -37,41 +38,35 @@ func generateOrderedYAML(infoMap map[string]interface{}) (string, error) {
37 // ContainerManagerEditGET displays the edit container view38 // ContainerManagerEditGET displays the edit container view
38 func ContainerManagerEditGET(c echo.Context, a *server.App) error {39 func ContainerManagerEditGET(c echo.Context, a *server.App) error {
39 containerID := c.Param("ID")40 containerID := c.Param("ID")
40-
41- // Get the container info
42 containerInfo, err := docker.GetContainerInfo(containerID)41 containerInfo, err := docker.GetContainerInfo(containerID)
43 if err != nil {42 if err != nil {
44 return sendError(c, err)43 return sendError(c, err)
45 }44 }
46 45
47- // Trim the slash from the container name46+ portMappings := make(map[string]string)
48- containerInfo.Name = strings.TrimPrefix(containerInfo.Name, "/")47+ for internalPort, hostBindings := range containerInfo.HostConfig.PortBindings {
49- // Get the network names48+ if len(hostBindings) > 0 {
50- networkNames := make([]string, 0, len(containerInfo.NetworkSettings.Networks))49+ hostBinding := hostBindings[0]
51- for networkName := range containerInfo.NetworkSettings.Networks {50+ portMappings[string(internalPort)] = fmt.Sprintf("%s:%s", hostBinding.HostIP, hostBinding.HostPort)
52- networkNames = append(networkNames, networkName)
53- }
54-
55- // Prepare Ports
56- portMappings := make([]string, 0)
57- for port, bindings := range containerInfo.HostConfig.PortBindings {
58- for _, binding := range bindings {
59- portMappings = append(portMappings, fmt.Sprintf("%s:%s/%s", binding.HostPort, port.Port(), port.Proto()))
60 }51 }
61 }52 }
62 53
63- // Prepare Volumes (Mounts)54+ volumeMappings := make(map[string]string)
64- volumeMappings := make([]string, 0)
65 for _, mount := range containerInfo.Mounts {55 for _, mount := range containerInfo.Mounts {
66- volumeMappings = append(volumeMappings, fmt.Sprintf("%s:%s", mount.Source, mount.Destination))56+ volumeMappings[mount.Source] = mount.Destination
67 }57 }
68 58
69- // Initialize a map to store the information from the container59+ networkNames := []string{}
70- infoMap := make(map[string]interface{})60+ for name := range containerInfo.NetworkSettings.Networks {
61+ networkNames = append(networkNames, name)
62+ }
71 63
72- // Populate the map with container details64+ infoMap := map[string]interface{}{
73- infoMap["Name"] = containerInfo.Name65+ "Name": strings.TrimPrefix(containerInfo.Name, "/"),
74- infoMap["Image"] = containerInfo.Config.Image66+ "Image": containerInfo.Config.Image,
67+ "CMD": containerInfo.Config.Cmd,
68+ "Entrypoint": containerInfo.Config.Entrypoint,
69+ }
75 infoMap["Hostname"] = containerInfo.Config.Hostname70 infoMap["Hostname"] = containerInfo.Config.Hostname
76 infoMap["Ports"] = portMappings71 infoMap["Ports"] = portMappings
77 infoMap["Volumes"] = volumeMappings72 infoMap["Volumes"] = volumeMappings
@@ -86,26 +81,11 @@ func ContainerManagerEditGET(c echo.Context, a *server.App) error {
86 return sendError(c, err)81 return sendError(c, err)
87 }82 }
88 83
89- // Pass the containerInfo to the data map84+ // Use the Templ renderer
90- data := map[string]interface{}{85+ renderer := render.NewTemplRenderer(a)
91- "Title": "Edit container",86+ component := components.EditContainer(containerID, yamlString)
92- "ID": containerID,
93- "ContainerInfo": yamlString,
94- }
95-
96- rendererData, err := render.GetHTMLRenderer("html/fragments", "editcontainer.gohtml", a.TemplateFS, a)
97-
98- if err != nil {
99- return sendError(c, err)
100- }
101-
102- renderedHTML, err := rendererData.Render(data, a)
103-
104- if err != nil {
105- return sendError(c, err)
106- }
107 87
108- return c.HTML(200, renderedHTML)88+ return renderer.RenderTempl(c, component)
109 }89 }
110 90
111 type TransactionStep func() error91 type TransactionStep func() error
M internal/httpserve/handlers/login.go +79 -47
@@ -4,16 +4,17 @@ import (
44 "database/sql"
55 "encoding/base64"
66 "encoding/json"
7+ "errors"
78 "fmt"
89 "io"
910 "net/http"
1011 "net/url"
12+ "strings"
1113 "time"
1214
1315 "github.com/bnema/gordon/internal/db"
1416 "github.com/bnema/gordon/internal/db/queries"
1517 "github.com/bnema/gordon/internal/server"
16- "github.com/bnema/gordon/internal/templating/render"
1718 "github.com/bnema/gordon/pkg/logger"
1819 "github.com/golang-jwt/jwt/v5"
1920 "github.com/gorilla/sessions"
@@ -28,47 +29,6 @@ type JwtCustomClaims struct {
2829 jwt.RegisteredClaims
2930 }
3031
31-// RenderLoginPage renders the login.html template (or redirects if already logged in)
32-// NOTE: This function might be superseded by RenderTemplLoginPage depending on router setup.
33-// Keeping it for now, but it needs adjustment if used.
34-func RenderLoginPage(c echo.Context, a *server.App) error {
35- // Check if user is already authenticated via session
36- sess, _ := getSession(c) // Ignore error for check
37- if sess != nil && sess.Values != nil {
38- if authenticated, ok := sess.Values["authenticated"].(bool); ok && authenticated {
39- // If authenticated, redirect to admin panel
40- logger.Debug("User already authenticated, redirecting to admin panel")
41- return c.Redirect(http.StatusFound, a.Config.Admin.Path)
42- }
43- }
44-
45- // Check for error message from previous attempts (e.g., GitHub auth failure)
46- errorMsg := c.QueryParam("error")
47-
48- data := map[string]interface{}{
49- "Title": "Login",
50- "Error": errorMsg,
51- // Add other necessary data for the template
52- }
53-
54- // Navigate inside the fs.FS to get the template
55- // TODO: Verify the correct template path and name (e.g., "html/login/index.gohtml")
56- path := "html/login"
57- rendererData, err := render.GetHTMLRenderer(path, "index.gohtml", a.TemplateFS, a)
58- if err != nil {
59- logger.Error("Failed to get HTML renderer for login page", "error", err)
60- return err // Or render a generic error page
61- }
62-
63- renderedHTML, err := rendererData.Render(data, a)
64- if err != nil {
65- logger.Error("Failed to render login page HTML", "error", err)
66- return err // Or render a generic error page
67- }
68-
69- return c.HTML(http.StatusOK, renderedHTML)
70-}
71-
7232 // StartOAuthGithub starts the Github OAuth flow.
7333 func StartOAuthGithub(c echo.Context, a *server.App) error {
7434 logger.Info("Attempting to get an existing session")
@@ -121,6 +81,14 @@ func StartOAuthGithub(c echo.Context, a *server.App) error {
12181 return fmt.Errorf("could not extend session expiration: %w", err)
12282 }
12383
84+ // Explicitly mark the session as online since we've validated it
85+ logger.Info("Marking existing session as online", "sessionID", sessionID)
86+ err = queries.MarkSessionActive(a.DB, sessionID)
87+ if err != nil {
88+ logger.Error("Failed to mark session as online", "error", err, "sessionID", sessionID)
89+ return fmt.Errorf("failed to mark session online: %w", err)
90+ }
91+
12492 logger.Info("Session is valid and extended. Redirecting to admin panel")
12593 return c.Redirect(http.StatusFound, a.Config.Admin.Path)
12694 }
@@ -191,8 +159,43 @@ func OAuthCallback(c echo.Context, a *server.App) error {
191159
192160 user, accountID, session, err := handleUser(a, accessToken, browserInfo, userInfo)
193161 if err != nil {
194- logger.Error("Failed to handle user", "error", err)
195- return err
162+ logger.Error("Failed to handle user during OAuth callback", "error", err)
163+
164+ // --- MODIFIED: Redirect to login with specific error based on err type ---
165+ loginPath := a.Config.Admin.Path + "/login"
166+ errorParam := "error=unknown_error" // Default error
167+
168+ var httpErr *echo.HTTPError
169+ if errors.As(err, &httpErr) {
170+ switch httpErr.Code {
171+ case http.StatusUnauthorized:
172+ // Check the message to differentiate unauthorized reasons
173+ if strings.Contains(httpErr.Message.(string), "does not match the allowed email") {
174+ errorParam = "error=email_mismatch"
175+ } else if strings.Contains(httpErr.Message.(string), "no public email address") {
176+ errorParam = "error=no_email"
177+ } else {
178+ errorParam = "error=unauthorized"
179+ }
180+ case http.StatusInternalServerError:
181+ // Check for specific internal errors if needed, e.g., config issue
182+ if strings.Contains(httpErr.Message.(string), "Admin email not set") {
183+ errorParam = "error=config_email_missing"
184+ } else {
185+ errorParam = "error=server_error"
186+ }
187+ default:
188+ errorParam = "error=server_error" // Fallback for other HTTP errors
189+ }
190+ } else {
191+ // Handle non-HTTP errors (like DB connection issues, etc.)
192+ errorParam = "error=server_error"
193+ }
194+
195+ redirectURL := loginPath + "?" + errorParam
196+ logger.Debug("Redirecting user to login page with error", "url", redirectURL)
197+ return c.Redirect(http.StatusSeeOther, redirectURL)
198+ // --- END MODIFICATION ---
196199 }
197200 logger.Info("User handled successfully", "user_id", user.ID, "account_id", accountID)
198201
@@ -200,7 +203,8 @@ func OAuthCallback(c echo.Context, a *server.App) error {
200203 err = setSessionValues(c, sess, accountID, session)
201204 if err != nil {
202205 // Error is already logged within setSessionValues
203- return err // Return immediately if saving failed
206+ // Redirect to login page with a server error message
207+ return c.Redirect(http.StatusSeeOther, a.Config.Admin.Path+"/login?error=server_error")
204208 }
205209 // Log success ONLY after setSessionValues returns without error
206210 logger.Info("Session values set and saved successfully")
@@ -260,7 +264,35 @@ func handleUser(a *server.App, accessToken, browserInfo string, userInfo *db.Git
260264
261265 // --- Case 2: Subsequent GitHub logins ---
262266 if providerCount > 0 {
263- logger.Debug("Existing provider(s) found. Checking if incoming GitHub login matches.")
267+ logger.Debug("Existing provider(s) found. Checking if incoming GitHub user matches.")
268+
269+ // --- ADDED: Check if incoming GitHub user email matches configured email ---
270+ configuredEmail := a.Config.ReverseProxy.Email
271+ if configuredEmail == "" {
272+ logger.Error("Configuration Error: ReverseProxy.Email is not set in config.yml. Cannot validate user email.")
273+ // Prevent login if the email isn't configured, as it's required for validation.
274+ return nil, "", nil, echo.NewHTTPError(http.StatusInternalServerError, "Server configuration error: Admin email not set.")
275+ }
276+
277+ githubEmail := ""
278+ if len(userInfo.Emails) > 0 {
279+ githubEmail = userInfo.Emails[0] // Assuming the first email is primary
280+ }
281+
282+ if githubEmail == "" {
283+ logger.Warn("Unauthorized GitHub login attempt: User has no public email address on GitHub.", "github_login", userInfo.Login)
284+ return nil, "", nil, echo.NewHTTPError(http.StatusUnauthorized, "GitHub account must have a public primary email address.")
285+ }
286+
287+ if !strings.EqualFold(githubEmail, configuredEmail) {
288+ logger.Warn("Unauthorized GitHub login attempt: Email mismatch.",
289+ "github_login", userInfo.Login,
290+ "github_email", githubEmail,
291+ "configured_email", configuredEmail)
292+ return nil, "", nil, echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("This GitHub account's email (%s) does not match the allowed email.", githubEmail))
293+ }
294+ logger.Debug("GitHub user email matches configured admin email.", "email", githubEmail)
295+ // --- END: Email Check ---
264296
265297 // --- Check if incoming GitHub user matches an existing provider ---
266298 existingUser, existingAccountID, err := queries.GetUserByProviderLogin(a.DB, "github", userInfo.Login)
@@ -347,7 +379,7 @@ func setSessionValues(c echo.Context, sess *sessions.Session, accountID string,
347379 sess.Values["accountID"] = accountID
348380 sess.Values["expires"] = session.Expires
349381 sess.Values["sessionID"] = session.ID
350- sess.Values["isOnline"] = session.IsOnline
382+ sess.Values["isActive"] = session.IsActive
351383
352384 if err := sess.Save(c.Request(), c.Response()); err != nil {
353385 logger.Error("Failed to save session cookie", "error", err, "sessionID", session.ID)
@@ -4,16 +4,17 @@ import (
4 "database/sql"4 "database/sql"
5 "encoding/base64"5 "encoding/base64"
6 "encoding/json"6 "encoding/json"
7+ "errors"
7 "fmt"8 "fmt"
8 "io"9 "io"
9 "net/http"10 "net/http"
10 "net/url"11 "net/url"
12+ "strings"
11 "time"13 "time"
12 14
13 "github.com/bnema/gordon/internal/db"15 "github.com/bnema/gordon/internal/db"
14 "github.com/bnema/gordon/internal/db/queries"16 "github.com/bnema/gordon/internal/db/queries"
15 "github.com/bnema/gordon/internal/server"17 "github.com/bnema/gordon/internal/server"
16- "github.com/bnema/gordon/internal/templating/render"
17 "github.com/bnema/gordon/pkg/logger"18 "github.com/bnema/gordon/pkg/logger"
18 "github.com/golang-jwt/jwt/v5"19 "github.com/golang-jwt/jwt/v5"
19 "github.com/gorilla/sessions"20 "github.com/gorilla/sessions"
@@ -28,47 +29,6 @@ type JwtCustomClaims struct {
28 jwt.RegisteredClaims29 jwt.RegisteredClaims
29 }30 }
30 31
31-// RenderLoginPage renders the login.html template (or redirects if already logged in)
32-// NOTE: This function might be superseded by RenderTemplLoginPage depending on router setup.
33-// Keeping it for now, but it needs adjustment if used.
34-func RenderLoginPage(c echo.Context, a *server.App) error {
35- // Check if user is already authenticated via session
36- sess, _ := getSession(c) // Ignore error for check
37- if sess != nil && sess.Values != nil {
38- if authenticated, ok := sess.Values["authenticated"].(bool); ok && authenticated {
39- // If authenticated, redirect to admin panel
40- logger.Debug("User already authenticated, redirecting to admin panel")
41- return c.Redirect(http.StatusFound, a.Config.Admin.Path)
42- }
43- }
44-
45- // Check for error message from previous attempts (e.g., GitHub auth failure)
46- errorMsg := c.QueryParam("error")
47-
48- data := map[string]interface{}{
49- "Title": "Login",
50- "Error": errorMsg,
51- // Add other necessary data for the template
52- }
53-
54- // Navigate inside the fs.FS to get the template
55- // TODO: Verify the correct template path and name (e.g., "html/login/index.gohtml")
56- path := "html/login"
57- rendererData, err := render.GetHTMLRenderer(path, "index.gohtml", a.TemplateFS, a)
58- if err != nil {
59- logger.Error("Failed to get HTML renderer for login page", "error", err)
60- return err // Or render a generic error page
61- }
62-
63- renderedHTML, err := rendererData.Render(data, a)
64- if err != nil {
65- logger.Error("Failed to render login page HTML", "error", err)
66- return err // Or render a generic error page
67- }
68-
69- return c.HTML(http.StatusOK, renderedHTML)
70-}
71-
72 // StartOAuthGithub starts the Github OAuth flow.32 // StartOAuthGithub starts the Github OAuth flow.
73 func StartOAuthGithub(c echo.Context, a *server.App) error {33 func StartOAuthGithub(c echo.Context, a *server.App) error {
74 logger.Info("Attempting to get an existing session")34 logger.Info("Attempting to get an existing session")
@@ -121,6 +81,14 @@ func StartOAuthGithub(c echo.Context, a *server.App) error {
121 return fmt.Errorf("could not extend session expiration: %w", err)81 return fmt.Errorf("could not extend session expiration: %w", err)
122 }82 }
123 83
84+ // Explicitly mark the session as online since we've validated it
85+ logger.Info("Marking existing session as online", "sessionID", sessionID)
86+ err = queries.MarkSessionActive(a.DB, sessionID)
87+ if err != nil {
88+ logger.Error("Failed to mark session as online", "error", err, "sessionID", sessionID)
89+ return fmt.Errorf("failed to mark session online: %w", err)
90+ }
91+
124 logger.Info("Session is valid and extended. Redirecting to admin panel")92 logger.Info("Session is valid and extended. Redirecting to admin panel")
125 return c.Redirect(http.StatusFound, a.Config.Admin.Path)93 return c.Redirect(http.StatusFound, a.Config.Admin.Path)
126 }94 }
@@ -191,8 +159,43 @@ func OAuthCallback(c echo.Context, a *server.App) error {
191 159
192 user, accountID, session, err := handleUser(a, accessToken, browserInfo, userInfo)160 user, accountID, session, err := handleUser(a, accessToken, browserInfo, userInfo)
193 if err != nil {161 if err != nil {
194- logger.Error("Failed to handle user", "error", err)162+ logger.Error("Failed to handle user during OAuth callback", "error", err)
195- return err163+
164+ // --- MODIFIED: Redirect to login with specific error based on err type ---
165+ loginPath := a.Config.Admin.Path + "/login"
166+ errorParam := "error=unknown_error" // Default error
167+
168+ var httpErr *echo.HTTPError
169+ if errors.As(err, &httpErr) {
170+ switch httpErr.Code {
171+ case http.StatusUnauthorized:
172+ // Check the message to differentiate unauthorized reasons
173+ if strings.Contains(httpErr.Message.(string), "does not match the allowed email") {
174+ errorParam = "error=email_mismatch"
175+ } else if strings.Contains(httpErr.Message.(string), "no public email address") {
176+ errorParam = "error=no_email"
177+ } else {
178+ errorParam = "error=unauthorized"
179+ }
180+ case http.StatusInternalServerError:
181+ // Check for specific internal errors if needed, e.g., config issue
182+ if strings.Contains(httpErr.Message.(string), "Admin email not set") {
183+ errorParam = "error=config_email_missing"
184+ } else {
185+ errorParam = "error=server_error"
186+ }
187+ default:
188+ errorParam = "error=server_error" // Fallback for other HTTP errors
189+ }
190+ } else {
191+ // Handle non-HTTP errors (like DB connection issues, etc.)
192+ errorParam = "error=server_error"
193+ }
194+
195+ redirectURL := loginPath + "?" + errorParam
196+ logger.Debug("Redirecting user to login page with error", "url", redirectURL)
197+ return c.Redirect(http.StatusSeeOther, redirectURL)
198+ // --- END MODIFICATION ---
196 }199 }
197 logger.Info("User handled successfully", "user_id", user.ID, "account_id", accountID)200 logger.Info("User handled successfully", "user_id", user.ID, "account_id", accountID)
198 201
@@ -200,7 +203,8 @@ func OAuthCallback(c echo.Context, a *server.App) error {
200 err = setSessionValues(c, sess, accountID, session)203 err = setSessionValues(c, sess, accountID, session)
201 if err != nil {204 if err != nil {
202 // Error is already logged within setSessionValues205 // Error is already logged within setSessionValues
203- return err // Return immediately if saving failed206+ // Redirect to login page with a server error message
207+ return c.Redirect(http.StatusSeeOther, a.Config.Admin.Path+"/login?error=server_error")
204 }208 }
205 // Log success ONLY after setSessionValues returns without error209 // Log success ONLY after setSessionValues returns without error
206 logger.Info("Session values set and saved successfully")210 logger.Info("Session values set and saved successfully")
@@ -260,7 +264,35 @@ func handleUser(a *server.App, accessToken, browserInfo string, userInfo *db.Git
260 264
261 // --- Case 2: Subsequent GitHub logins ---265 // --- Case 2: Subsequent GitHub logins ---
262 if providerCount > 0 {266 if providerCount > 0 {
263- logger.Debug("Existing provider(s) found. Checking if incoming GitHub login matches.")267+ logger.Debug("Existing provider(s) found. Checking if incoming GitHub user matches.")
268+
269+ // --- ADDED: Check if incoming GitHub user email matches configured email ---
270+ configuredEmail := a.Config.ReverseProxy.Email
271+ if configuredEmail == "" {
272+ logger.Error("Configuration Error: ReverseProxy.Email is not set in config.yml. Cannot validate user email.")
273+ // Prevent login if the email isn't configured, as it's required for validation.
274+ return nil, "", nil, echo.NewHTTPError(http.StatusInternalServerError, "Server configuration error: Admin email not set.")
275+ }
276+
277+ githubEmail := ""
278+ if len(userInfo.Emails) > 0 {
279+ githubEmail = userInfo.Emails[0] // Assuming the first email is primary
280+ }
281+
282+ if githubEmail == "" {
283+ logger.Warn("Unauthorized GitHub login attempt: User has no public email address on GitHub.", "github_login", userInfo.Login)
284+ return nil, "", nil, echo.NewHTTPError(http.StatusUnauthorized, "GitHub account must have a public primary email address.")
285+ }
286+
287+ if !strings.EqualFold(githubEmail, configuredEmail) {
288+ logger.Warn("Unauthorized GitHub login attempt: Email mismatch.",
289+ "github_login", userInfo.Login,
290+ "github_email", githubEmail,
291+ "configured_email", configuredEmail)
292+ return nil, "", nil, echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("This GitHub account's email (%s) does not match the allowed email.", githubEmail))
293+ }
294+ logger.Debug("GitHub user email matches configured admin email.", "email", githubEmail)
295+ // --- END: Email Check ---
264 296
265 // --- Check if incoming GitHub user matches an existing provider ---297 // --- Check if incoming GitHub user matches an existing provider ---
266 existingUser, existingAccountID, err := queries.GetUserByProviderLogin(a.DB, "github", userInfo.Login)298 existingUser, existingAccountID, err := queries.GetUserByProviderLogin(a.DB, "github", userInfo.Login)
@@ -347,7 +379,7 @@ func setSessionValues(c echo.Context, sess *sessions.Session, accountID string,
347 sess.Values["accountID"] = accountID379 sess.Values["accountID"] = accountID
348 sess.Values["expires"] = session.Expires380 sess.Values["expires"] = session.Expires
349 sess.Values["sessionID"] = session.ID381 sess.Values["sessionID"] = session.ID
350- sess.Values["isOnline"] = session.IsOnline382+ sess.Values["isActive"] = session.IsActive
351 383
352 if err := sess.Save(c.Request(), c.Response()); err != nil {384 if err := sess.Save(c.Request(), c.Response()); err != nil {
353 logger.Error("Failed to save session cookie", "error", err, "sessionID", session.ID)385 logger.Error("Failed to save session cookie", "error", err, "sessionID", session.ID)
M internal/httpserve/handlers/logout.go +1 -1
@@ -23,7 +23,7 @@ func Logout(c echo.Context, a *server.App) error {
2323 // Invalidate the session in the database
2424 sessionID, ok := sess.Values["sessionID"].(string)
2525 if ok {
26- err := queries.InvalidateDBSession(a.DB, sessionID)
26+ err := queries.MarkSessionInactive(a.DB, sessionID)
2727 if err != nil {
2828 return fmt.Errorf("failed to invalidate session: %w", err)
2929 }
@@ -23,7 +23,7 @@ func Logout(c echo.Context, a *server.App) error {
23 // Invalidate the session in the database23 // Invalidate the session in the database
24 sessionID, ok := sess.Values["sessionID"].(string)24 sessionID, ok := sess.Values["sessionID"].(string)
25 if ok {25 if ok {
26- err := queries.InvalidateDBSession(a.DB, sessionID)26+ err := queries.MarkSessionInactive(a.DB, sessionID)
27 if err != nil {27 if err != nil {
28 return fmt.Errorf("failed to invalidate session: %w", err)28 return fmt.Errorf("failed to invalidate session: %w", err)
29 }29 }
M internal/httpserve/handlers/session_db.go +10 -8
@@ -49,13 +49,13 @@ func ValidateSessionAndUser(c echo.Context, a *server.App) error {
4949 return fmt.Errorf("account does not exist")
5050 }
5151
52- isValidInDB, err := IsSessionValidInDB(a, sessionID)
52+ isValidInDB, err := IsSessionActiveInDB(a, sessionID)
5353 if err != nil {
5454 logger.Error("Failed to check session validity in DB", "error", err, "sessionID", sessionID)
5555 return fmt.Errorf("failed checking session validity in DB: %w", err)
5656 }
5757 if !isValidInDB {
58- logger.Warn("Session is invalid in DB (expired or not online)", "sessionID", sessionID)
58+ logger.Warn("Session is invalid in DB (expired or not active)", "sessionID", sessionID)
5959 return fmt.Errorf("session invalid or expired")
6060 }
6161
@@ -73,12 +73,12 @@ func ValidateSessionAndUser(c echo.Context, a *server.App) error {
7373 return nil
7474 }
7575
76-func IsSessionValidInDB(a *server.App, sessionID string) (bool, error) {
76+func IsSessionActiveInDB(a *server.App, sessionID string) (bool, error) {
7777 var dbExpiresStr string
78- var isOnline bool
78+ var isActive bool
7979
80- query := "SELECT expires, is_online FROM sessions WHERE id = ?"
81- err := a.DB.QueryRow(query, sessionID).Scan(&dbExpiresStr, &isOnline)
80+ query := "SELECT expires, is_active FROM sessions WHERE id = ?"
81+ err := a.DB.QueryRow(query, sessionID).Scan(&dbExpiresStr, &isActive)
8282
8383 if err != nil {
8484 if err == sql.ErrNoRows {
@@ -89,8 +89,10 @@ func IsSessionValidInDB(a *server.App, sessionID string) (bool, error) {
8989 return false, err
9090 }
9191
92- if !isOnline {
93- logger.Warn("Session found in DB but marked as not online", "sessionID", sessionID)
92+ logger.Debug("Session validity check: Read DB values", "sessionID", sessionID, "isActive_read", isActive, "expires_read", dbExpiresStr)
93+
94+ if !isActive {
95+ logger.Warn("Session found in DB but marked as not active", "sessionID", sessionID)
9496 return false, nil
9597 }
9698
@@ -49,13 +49,13 @@ func ValidateSessionAndUser(c echo.Context, a *server.App) error {
49 return fmt.Errorf("account does not exist")49 return fmt.Errorf("account does not exist")
50 }50 }
51 51
52- isValidInDB, err := IsSessionValidInDB(a, sessionID)52+ isValidInDB, err := IsSessionActiveInDB(a, sessionID)
53 if err != nil {53 if err != nil {
54 logger.Error("Failed to check session validity in DB", "error", err, "sessionID", sessionID)54 logger.Error("Failed to check session validity in DB", "error", err, "sessionID", sessionID)
55 return fmt.Errorf("failed checking session validity in DB: %w", err)55 return fmt.Errorf("failed checking session validity in DB: %w", err)
56 }56 }
57 if !isValidInDB {57 if !isValidInDB {
58- logger.Warn("Session is invalid in DB (expired or not online)", "sessionID", sessionID)58+ logger.Warn("Session is invalid in DB (expired or not active)", "sessionID", sessionID)
59 return fmt.Errorf("session invalid or expired")59 return fmt.Errorf("session invalid or expired")
60 }60 }
61 61
@@ -73,12 +73,12 @@ func ValidateSessionAndUser(c echo.Context, a *server.App) error {
73 return nil73 return nil
74 }74 }
75 75
76-func IsSessionValidInDB(a *server.App, sessionID string) (bool, error) {76+func IsSessionActiveInDB(a *server.App, sessionID string) (bool, error) {
77 var dbExpiresStr string77 var dbExpiresStr string
78- var isOnline bool78+ var isActive bool
79 79
80- query := "SELECT expires, is_online FROM sessions WHERE id = ?"80+ query := "SELECT expires, is_active FROM sessions WHERE id = ?"
81- err := a.DB.QueryRow(query, sessionID).Scan(&dbExpiresStr, &isOnline)81+ err := a.DB.QueryRow(query, sessionID).Scan(&dbExpiresStr, &isActive)
82 82
83 if err != nil {83 if err != nil {
84 if err == sql.ErrNoRows {84 if err == sql.ErrNoRows {
@@ -89,8 +89,10 @@ func IsSessionValidInDB(a *server.App, sessionID string) (bool, error) {
89 return false, err89 return false, err
90 }90 }
91 91
92- if !isOnline {92+ logger.Debug("Session validity check: Read DB values", "sessionID", sessionID, "isActive_read", isActive, "expires_read", dbExpiresStr)
93- logger.Warn("Session found in DB but marked as not online", "sessionID", sessionID)93+
94+ if !isActive {
95+ logger.Warn("Session found in DB but marked as not active", "sessionID", sessionID)
94 return false, nil96 return false, nil
95 }97 }
96 98
M internal/httpserve/handlers/upload_image.go +5 -13
@@ -7,6 +7,7 @@ import (
77 "os"
88
99 "github.com/bnema/gordon/internal/server"
10+ "github.com/bnema/gordon/internal/templating/models/templ/components"
1011 "github.com/bnema/gordon/internal/templating/render"
1112 "github.com/bnema/gordon/pkg/docker"
1213 "github.com/bnema/gordon/pkg/filestore"
@@ -18,20 +19,11 @@ const MaxUploadSize = 10 * 1024 * 1024 * 1024 // 10GB
1819
1920 // UploadImageHandler handles the /upload-image route to show the form
2021 func UploadImageGETHandler(c echo.Context, a *server.App) error {
21- data := map[string]interface{}{
22- "Title": "Upload Image",
23- }
22+ // Use the Templ renderer
23+ renderer := render.NewTemplRenderer(a)
24+ component := components.UploadImageForm()
2425
25- rendererData, err := render.GetHTMLRenderer("html/fragments", "uploadimage.gohtml", a.TemplateFS, a)
26- if err != nil {
27- return sendError(c, err)
28- }
29-
30- renderedHTML, err := rendererData.Render(data, a)
31- if err != nil {
32- return err
33- }
34- return c.HTML(200, renderedHTML)
26+ return renderer.RenderTempl(c, component)
3527 }
3628
3729 // UploadImageHandler handles the /upload-image
@@ -7,6 +7,7 @@ import (
7 "os"7 "os"
8 8
9 "github.com/bnema/gordon/internal/server"9 "github.com/bnema/gordon/internal/server"
10+ "github.com/bnema/gordon/internal/templating/models/templ/components"
10 "github.com/bnema/gordon/internal/templating/render"11 "github.com/bnema/gordon/internal/templating/render"
11 "github.com/bnema/gordon/pkg/docker"12 "github.com/bnema/gordon/pkg/docker"
12 "github.com/bnema/gordon/pkg/filestore"13 "github.com/bnema/gordon/pkg/filestore"
@@ -18,20 +19,11 @@ const MaxUploadSize = 10 * 1024 * 1024 * 1024 // 10GB
18 19
19 // UploadImageHandler handles the /upload-image route to show the form20 // UploadImageHandler handles the /upload-image route to show the form
20 func UploadImageGETHandler(c echo.Context, a *server.App) error {21 func UploadImageGETHandler(c echo.Context, a *server.App) error {
21- data := map[string]interface{}{22+ // Use the Templ renderer
22- "Title": "Upload Image",23+ renderer := render.NewTemplRenderer(a)
23- }24+ component := components.UploadImageForm()
24 25
25- rendererData, err := render.GetHTMLRenderer("html/fragments", "uploadimage.gohtml", a.TemplateFS, a)26+ return renderer.RenderTempl(c, component)
26- if err != nil {
27- return sendError(c, err)
28- }
29-
30- renderedHTML, err := rendererData.Render(data, a)
31- if err != nil {
32- return err
33- }
34- return c.HTML(200, renderedHTML)
35 }27 }
36 28
37 // UploadImageHandler handles the /upload-image29 // UploadImageHandler handles the /upload-image
M internal/httpserve/middleware/secure.go +2 -0
@@ -2,6 +2,7 @@ package middleware
22
33 import (
44 "log"
5+ "net/http"
56 "os"
67
78 "github.com/bnema/gordon/internal/server"
@@ -38,6 +39,7 @@ func InitSessionMiddleware(a *server.App) echo.MiddlewareFunc {
3839 HttpOnly: true,
3940 Secure: isHttps,
4041 MaxAge: 86400,
42+ SameSite: http.SameSiteLaxMode,
4143 }
4244 return func(next echo.HandlerFunc) echo.HandlerFunc {
4345 return func(c echo.Context) error {
@@ -2,6 +2,7 @@ package middleware
2 2
3 import (3 import (
4 "log"4 "log"
5+ "net/http"
5 "os"6 "os"
6 7
7 "github.com/bnema/gordon/internal/server"8 "github.com/bnema/gordon/internal/server"
@@ -38,6 +39,7 @@ func InitSessionMiddleware(a *server.App) echo.MiddlewareFunc {
38 HttpOnly: true,39 HttpOnly: true,
39 Secure: isHttps,40 Secure: isHttps,
40 MaxAge: 86400,41 MaxAge: 86400,
42+ SameSite: http.SameSiteLaxMode,
41 }43 }
42 return func(next echo.HandlerFunc) echo.HandlerFunc {44 return func(next echo.HandlerFunc) echo.HandlerFunc {
43 return func(c echo.Context) error {45 return func(c echo.Context) error {
M internal/httpserve/router.go +16 -4
@@ -132,14 +132,26 @@ func bindLoginRoute(e *echo.Echo, a *server.App, adminPath string) {
132132 }
133133
134134 e.GET(adminPath+"/login", func(c echo.Context) error {
135- // Check if already logged in, if so redirect to admin panel, otherwise render login page
136- sess, _ := session.Get("session", c) // Use echo-contrib session
135+ // Check if already logged in based on cookie
136+ sess, _ := session.Get("session", c) // Ignore error for initial check
137137 if sess != nil && sess.Values != nil {
138138 if authenticated, ok := sess.Values["authenticated"].(bool); ok && authenticated {
139- return c.Redirect(http.StatusFound, a.Config.Admin.Path)
139+ // Cookie claims authenticated. NOW, validate against the DB too.
140+ err := handlers.ValidateSessionAndUser(c, a) // Use the same validation function as the middleware
141+ if err == nil {
142+ // Session is valid in BOTH cookie AND DB. Proceed to admin panel.
143+ log.Debug("Login page check: Valid session found (cookie & DB), redirecting to admin panel")
144+ return c.Redirect(http.StatusFound, a.Config.Admin.Path)
145+ }
146+ // If DB validation failed, log it and fall through to render login page
147+ log.Warn("Login page check: Cookie session present but DB validation failed", "error", err)
148+ // NOTE: Consider invalidating the inconsistent cookie here?
149+ // handlers.InvalidateSessionCookie(c, sess) // Optional: Force cookie cleanup
140150 }
141151 }
142- // If not authenticated, render the login page (which should now only contain GitHub login)
152+ // If no session cookie, not authenticated in cookie, or DB validation failed: render login page.
153+ log.Debug("Login page check: No valid session found or DB validation failed, rendering login page")
154+ // Always use the Templ-based login page renderer
143155 return handlers.RenderTemplLoginPage(c, a)
144156 })
145157 // Removed POST /login/submit-token route
@@ -132,14 +132,26 @@ func bindLoginRoute(e *echo.Echo, a *server.App, adminPath string) {
132 }132 }
133 133
134 e.GET(adminPath+"/login", func(c echo.Context) error {134 e.GET(adminPath+"/login", func(c echo.Context) error {
135- // Check if already logged in, if so redirect to admin panel, otherwise render login page135+ // Check if already logged in based on cookie
136- sess, _ := session.Get("session", c) // Use echo-contrib session136+ sess, _ := session.Get("session", c) // Ignore error for initial check
137 if sess != nil && sess.Values != nil {137 if sess != nil && sess.Values != nil {
138 if authenticated, ok := sess.Values["authenticated"].(bool); ok && authenticated {138 if authenticated, ok := sess.Values["authenticated"].(bool); ok && authenticated {
139- return c.Redirect(http.StatusFound, a.Config.Admin.Path)139+ // Cookie claims authenticated. NOW, validate against the DB too.
140+ err := handlers.ValidateSessionAndUser(c, a) // Use the same validation function as the middleware
141+ if err == nil {
142+ // Session is valid in BOTH cookie AND DB. Proceed to admin panel.
143+ log.Debug("Login page check: Valid session found (cookie & DB), redirecting to admin panel")
144+ return c.Redirect(http.StatusFound, a.Config.Admin.Path)
145+ }
146+ // If DB validation failed, log it and fall through to render login page
147+ log.Warn("Login page check: Cookie session present but DB validation failed", "error", err)
148+ // NOTE: Consider invalidating the inconsistent cookie here?
149+ // handlers.InvalidateSessionCookie(c, sess) // Optional: Force cookie cleanup
140 }150 }
141 }151 }
142- // If not authenticated, render the login page (which should now only contain GitHub login)152+ // If no session cookie, not authenticated in cookie, or DB validation failed: render login page.
153+ log.Debug("Login page check: No valid session found or DB validation failed, rendering login page")
154+ // Always use the Templ-based login page renderer
143 return handlers.RenderTemplLoginPage(c, a)155 return handlers.RenderTemplLoginPage(c, a)
144 })156 })
145 // Removed POST /login/submit-token route157 // Removed POST /login/submit-token route
M internal/server/sqlite_init.go +2 -2
@@ -630,7 +630,7 @@ func (p *ProviderWrapper) ScanFromRows(rows *sql.Rows) error {
630630 }
631631
632632 func (s *SessionsWrapper) ScanFromRows(rows *sql.Rows) error {
633- return rows.Scan(&s.ID, &s.AccountID, &s.AccessToken, &s.BrowserInfo, &s.Expires, &s.IsOnline)
633+ return rows.Scan(&s.ID, &s.AccountID, &s.AccessToken, &s.BrowserInfo, &s.Expires, &s.IsActive)
634634 }
635635
636636 func (c *ClientsWrapper) ScanFromRows(rows *sql.Rows) error {
@@ -681,7 +681,7 @@ func PopulateStructWithTables(a *App) error {
681681
682682 // Populate the Sessions table
683683 sessionsWrapper := &SessionsWrapper{Sessions: &a.DBTables.Sessions}
684- if err := populateTableFromDB(a, "SELECT id, account_id, browser_info, access_token, expires, is_online FROM sessions", sessionsWrapper); err != nil {
684+ if err := populateTableFromDB(a, "SELECT id, account_id, browser_info, access_token, expires, is_active FROM sessions", sessionsWrapper); err != nil {
685685 return err
686686 }
687687
@@ -630,7 +630,7 @@ func (p *ProviderWrapper) ScanFromRows(rows *sql.Rows) error {
630 }630 }
631 631
632 func (s *SessionsWrapper) ScanFromRows(rows *sql.Rows) error {632 func (s *SessionsWrapper) ScanFromRows(rows *sql.Rows) error {
633- return rows.Scan(&s.ID, &s.AccountID, &s.AccessToken, &s.BrowserInfo, &s.Expires, &s.IsOnline)633+ return rows.Scan(&s.ID, &s.AccountID, &s.AccessToken, &s.BrowserInfo, &s.Expires, &s.IsActive)
634 }634 }
635 635
636 func (c *ClientsWrapper) ScanFromRows(rows *sql.Rows) error {636 func (c *ClientsWrapper) ScanFromRows(rows *sql.Rows) error {
@@ -681,7 +681,7 @@ func PopulateStructWithTables(a *App) error {
681 681
682 // Populate the Sessions table682 // Populate the Sessions table
683 sessionsWrapper := &SessionsWrapper{Sessions: &a.DBTables.Sessions}683 sessionsWrapper := &SessionsWrapper{Sessions: &a.DBTables.Sessions}
684- if err := populateTableFromDB(a, "SELECT id, account_id, browser_info, access_token, expires, is_online FROM sessions", sessionsWrapper); err != nil {684+ if err := populateTableFromDB(a, "SELECT id, account_id, browser_info, access_token, expires, is_active FROM sessions", sessionsWrapper); err != nil {
685 return err685 return err
686 }686 }
687 687
D internal/templating/models/html/admin/createcontainerfull.gohtml +0 -21
@@ -1,21 +0,0 @@
1-<!DOCTYPE html>
2-<html lang="en">
3- {{template "header.gohtml" .}}
4-
5- <body class="bg-app flex flex-col lg:flex-row p-0">
6- {{template "menu.gohtml" .}}
7- <!-- Main content here -->
8- <div class="flex-1 flex flex-col space-y-4 pt-16 md:pt-32 md:px-6">
9- <div class="flex flex-col md:flex-row w-full flex-grow">
10- <div class="card flex-grow">
11- <h1 class="card-header mb-4">Edit Container</h1>
12- <!-- Include the existing edit container fragment here -->
13- {{template "createcontainer.gohtml" .}}
14- </div>
15- </div>
16- </div>
17-
18- <!-- footer -->
19- {{template "footer.gohtml" .}}
20- </body>
21-</html>⏎̸
@@ -1,21 +0,0 @@
1-<!DOCTYPE html>
2-<html lang="en">
3- {{template "header.gohtml" .}}
4-
5- <body class="bg-app flex flex-col lg:flex-row p-0">
6- {{template "menu.gohtml" .}}
7- <!-- Main content here -->
8- <div class="flex-1 flex flex-col space-y-4 pt-16 md:pt-32 md:px-6">
9- <div class="flex flex-col md:flex-row w-full flex-grow">
10- <div class="card flex-grow">
11- <h1 class="card-header mb-4">Edit Container</h1>
12- <!-- Include the existing edit container fragment here -->
13- {{template "createcontainer.gohtml" .}}
14- </div>
15- </div>
16- </div>
17-
18- <!-- footer -->
19- {{template "footer.gohtml" .}}
20- </body>
21-</html>⏎̸
D internal/templating/models/html/admin/dashboard.gohtml +0 -9
@@ -1,9 +0,0 @@
1-<div id="dashboard">
2- <h1
3- class="text-5xl fw100 text-indigo-600 animate-bounce-alt animate-count-infinite animate-duration-1s"
4- >
5- Hello World from dashboard.html
6- </h1>
7- <!-- We load Chart.js here to avoid loading it in every page end when all the rest is loaded -->
8- <script src="libs/chart.js"></script>
9-</div>
@@ -1,9 +0,0 @@
1-<div id="dashboard">
2- <h1
3- class="text-5xl fw100 text-indigo-600 animate-bounce-alt animate-count-infinite animate-duration-1s"
4- >
5- Hello World from dashboard.html
6- </h1>
7- <!-- We load Chart.js here to avoid loading it in every page end when all the rest is loaded -->
8- <script src="libs/chart.js"></script>
9-</div>
D internal/templating/models/html/admin/index.gohtml +0 -29
@@ -1,29 +0,0 @@
1-<!doctype html>
2-<html lang="en">
3- {{template "header.gohtml" .}}
4-
5- <body class="bg-app flex flex-col md:flex-row p-4 md:p-0">
6- {{template "menu.gohtml" .}}
7- <div class="flex-1 flex flex-col md:pt-10 space-y-4 p-4 md:ml-4">
8- <div
9- class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow">
10- <div class="card flex-grow">
11- <h2 class="card-header">{{index .Lang "dashboard" "content" "title"}}</h2>
12- </div>
13- <div class="w-full md:w-2/6 flex flex-col space-y-4">
14- <div class="card">
15- <div class="w-full h-44 rounded-sm bg-indigo-100"></div>
16- </div>
17- <div class="card">
18- <div class="w-full h-44 rounded-sm bg-indigo-100"></div>
19- </div>
20- <div class="card">
21- <div class="w-full h-44 rounded-sm bg-indigo-100"></div>
22- </div>
23- </div>
24- </div>
25- </div>
26- <!-- footer -->
27- {{template "footer.gohtml" .}}
28- </body>
29-</html>
@@ -1,29 +0,0 @@
1-<!doctype html>
2-<html lang="en">
3- {{template "header.gohtml" .}}
4-
5- <body class="bg-app flex flex-col md:flex-row p-4 md:p-0">
6- {{template "menu.gohtml" .}}
7- <div class="flex-1 flex flex-col md:pt-10 space-y-4 p-4 md:ml-4">
8- <div
9- class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow">
10- <div class="card flex-grow">
11- <h2 class="card-header">{{index .Lang "dashboard" "content" "title"}}</h2>
12- </div>
13- <div class="w-full md:w-2/6 flex flex-col space-y-4">
14- <div class="card">
15- <div class="w-full h-44 rounded-sm bg-indigo-100"></div>
16- </div>
17- <div class="card">
18- <div class="w-full h-44 rounded-sm bg-indigo-100"></div>
19- </div>
20- <div class="card">
21- <div class="w-full h-44 rounded-sm bg-indigo-100"></div>
22- </div>
23- </div>
24- </div>
25- </div>
26- <!-- footer -->
27- {{template "footer.gohtml" .}}
28- </body>
29-</html>
D internal/templating/models/html/admin/install.gohtml +0 -77
@@ -1,77 +0,0 @@
1-<!DOCTYPE html>
2-<html lang="en">
3- <head>
4- <meta charset="UTF-8" />
5- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6- <title>{{ .header.meta.title }}</title>
7- <meta name="description" content="{{ .Header.Meta.Description }}" />
8- <meta name="keywords" content="{{ .Header.Meta.Keywords }}" />
9- <!-- Custom CSS + Tailwind -->
10- <link rel="stylesheet" href="/assets/css/tailwind.css" />
11- <!-- Iconoir -->
12- <link rel="stylesheet" href="/libs/iconoir.css" />
13- <!-- Alpine -->
14- <script src="/libs/alpine.min.js"></script>
15- <!-- HTMX min js -->
16- <script src="/libs/htmx.min.js"></script>
17- <!-- HTMX SSE -->
18- <script src="/libs/htmx-sse.js"></script>
19- <!-- Custom JS from assets/js -->
20- <script src="/assets/js/custom.js"></script>
21- </head>
22-
23- <body
24- class="IBMPlexSans bg-app md:p-8 flex flex-col items-center"
25- hx-boost="true"
26- >
27- <!-- Logo and Title -->
28- <div
29- id="logo-mascot"
30- class="flex flex-col justify-center items-center p-4 mb-4"
31- >
32- <div class="circle-shadow flex justify-center w-100">
33- <img
34- src="/assets/imgs/gordon-mascot-hq-trsp.png"
35- alt="logo"
36- class="sm:h-24 h-32 xl:h-48"
37- />
38- </div>
39- <div class="flex justify-center mt-4">
40- <h1
41- class="gordon-title text-2xl lg:text-4xl xl:text-5xl text-indigo-800 tracking-tighter"
42- >
43- Gordon
44- </h1>
45- <span class="gordon-v-subtitle text-indigo-600 text-sm ml-2"
46- >v0.0.1</span
47- >
48- </div>
49- </div>
50-
51- <!-- Main Content -->
52- <div class="card w-2/3">
53- <!-- Built-in Proxy -->
54- <div id="built-in-proxy" class="mt-8">
55- <div class="flex items-center mb-4">
56- <div class="w-8 h-8 bg-indigo-500 rounded-full flex items-center justify-center">
57- <span class="text-white font-bold">✓</span>
58- </div>
59- <h2 class="text-lg ml-2 text-title">Reverse Proxy</h2>
60- </div>
61-
62- <div class="bg-indigo-50 p-4 rounded-md">
63- <p class="mb-4">Gordon now uses a built-in reverse proxy to handle all domain routing and TLS certificates.</p>
64- <p>The proxy automatically:</p>
65- <ul class="list-disc ml-6 mb-4">
66- <li>Manages Let's Encrypt certificates</li>
67- <li>Routes domains to the appropriate containers</li>
68- <li>Handles HTTP to HTTPS redirects</li>
69- <li>Provides proper headers for forwarded requests</li>
70- </ul>
71- <p>No additional configuration is required.</p>
72- </div>
73- </div>
74- <!-- End Built-in Proxy -->
75- </div>
76- </body>
77-</html>
@@ -1,77 +0,0 @@
1-<!DOCTYPE html>
2-<html lang="en">
3- <head>
4- <meta charset="UTF-8" />
5- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6- <title>{{ .header.meta.title }}</title>
7- <meta name="description" content="{{ .Header.Meta.Description }}" />
8- <meta name="keywords" content="{{ .Header.Meta.Keywords }}" />
9- <!-- Custom CSS + Tailwind -->
10- <link rel="stylesheet" href="/assets/css/tailwind.css" />
11- <!-- Iconoir -->
12- <link rel="stylesheet" href="/libs/iconoir.css" />
13- <!-- Alpine -->
14- <script src="/libs/alpine.min.js"></script>
15- <!-- HTMX min js -->
16- <script src="/libs/htmx.min.js"></script>
17- <!-- HTMX SSE -->
18- <script src="/libs/htmx-sse.js"></script>
19- <!-- Custom JS from assets/js -->
20- <script src="/assets/js/custom.js"></script>
21- </head>
22-
23- <body
24- class="IBMPlexSans bg-app md:p-8 flex flex-col items-center"
25- hx-boost="true"
26- >
27- <!-- Logo and Title -->
28- <div
29- id="logo-mascot"
30- class="flex flex-col justify-center items-center p-4 mb-4"
31- >
32- <div class="circle-shadow flex justify-center w-100">
33- <img
34- src="/assets/imgs/gordon-mascot-hq-trsp.png"
35- alt="logo"
36- class="sm:h-24 h-32 xl:h-48"
37- />
38- </div>
39- <div class="flex justify-center mt-4">
40- <h1
41- class="gordon-title text-2xl lg:text-4xl xl:text-5xl text-indigo-800 tracking-tighter"
42- >
43- Gordon
44- </h1>
45- <span class="gordon-v-subtitle text-indigo-600 text-sm ml-2"
46- >v0.0.1</span
47- >
48- </div>
49- </div>
50-
51- <!-- Main Content -->
52- <div class="card w-2/3">
53- <!-- Built-in Proxy -->
54- <div id="built-in-proxy" class="mt-8">
55- <div class="flex items-center mb-4">
56- <div class="w-8 h-8 bg-indigo-500 rounded-full flex items-center justify-center">
57- <span class="text-white font-bold">✓</span>
58- </div>
59- <h2 class="text-lg ml-2 text-title">Reverse Proxy</h2>
60- </div>
61-
62- <div class="bg-indigo-50 p-4 rounded-md">
63- <p class="mb-4">Gordon now uses a built-in reverse proxy to handle all domain routing and TLS certificates.</p>
64- <p>The proxy automatically:</p>
65- <ul class="list-disc ml-6 mb-4">
66- <li>Manages Let's Encrypt certificates</li>
67- <li>Routes domains to the appropriate containers</li>
68- <li>Handles HTTP to HTTPS redirects</li>
69- <li>Provides proper headers for forwarded requests</li>
70- </ul>
71- <p>No additional configuration is required.</p>
72- </div>
73- </div>
74- <!-- End Built-in Proxy -->
75- </div>
76- </body>
77-</html>
D internal/templating/models/html/admin/manager.gohtml +0 -76
@@ -1,76 +0,0 @@
1-<!-- manager.gohtml -->
2-<!DOCTYPE html>
3-<html lang="en" data-theme="gordon">
4- {{template "header.gohtml" .}}
5-
6- <body class="min-h-screen bg-base-200">
7- {{template "menu.gohtml" .}}
8-
9- <!-- Main content -->
10- <div class="container mx-auto py-6 px-4 mt-16">
11- <div class="text-sm breadcrumbs mb-4">
12- <ul>
13- <li><a href="{{ .AdminPath }}">Gordon</a></li>
14- <li>Manager</li>
15- </ul>
16- </div>
17-
18- <!-- Stats overview -->
19- <div class="stats shadow mb-6 w-full">
20- <div class="stat">
21- <div class="stat-figure text-primary">
22- <span class="iconf text-3xl">󰡨</span>
23- </div>
24- <div class="stat-title">Containers</div>
25- <div class="stat-value" id="container-count">{{.ContainerCount}}</div>
26- <div class="stat-desc">Docker containers managed by Gordon</div>
27- </div>
28-
29- <div class="stat">
30- <div class="stat-figure text-secondary">
31- <span class="iconf text-3xl">󰋊</span>
32- </div>
33- <div class="stat-title">Images</div>
34- <div class="stat-value" id="image-count">{{.ImageCount}}</div>
35- <div class="stat-desc">Docker images available</div>
36- </div>
37- </div>
38-
39- <!-- Tabs for containers and images -->
40- <div role="tablist" class="tabs tabs-boxed mb-4">
41- <input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Containers" id="containers-tab" checked />
42- <div role="tabpanel" class="tab-content p-0">
43- <!-- Container manager section -->
44- <div id="container-manager"
45- hx-get="/htmx/container-manager"
46- hx-trigger="load"
47- hx-swap="innerHTML">
48- <!-- Container content loaded via HTMX -->
49- <div class="text-center p-4">
50- <span class="iconf text-3xl animate-spin inline-block">󰑐</span>
51- Loading containers...
52- </div>
53- </div>
54- </div>
55-
56- <input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Images" id="images-tab" />
57- <div role="tabpanel" class="tab-content p-0">
58- <!-- Image manager section -->
59- <div id="image-manager"
60- hx-get="/htmx/image-manager"
61- hx-trigger="load"
62- hx-swap="innerHTML">
63- <!-- Image content loaded via HTMX -->
64- <div class="text-center p-4">
65- <span class="iconf text-3xl animate-spin inline-block">󰑐</span>
66- Loading images...
67- </div>
68- </div>
69- </div>
70- </div>
71-
72- </div>
73-
74- {{template "footer.gohtml" .}}
75- </body>
76-</html>
@@ -1,76 +0,0 @@
1-<!-- manager.gohtml -->
2-<!DOCTYPE html>
3-<html lang="en" data-theme="gordon">
4- {{template "header.gohtml" .}}
5-
6- <body class="min-h-screen bg-base-200">
7- {{template "menu.gohtml" .}}
8-
9- <!-- Main content -->
10- <div class="container mx-auto py-6 px-4 mt-16">
11- <div class="text-sm breadcrumbs mb-4">
12- <ul>
13- <li><a href="{{ .AdminPath }}">Gordon</a></li>
14- <li>Manager</li>
15- </ul>
16- </div>
17-
18- <!-- Stats overview -->
19- <div class="stats shadow mb-6 w-full">
20- <div class="stat">
21- <div class="stat-figure text-primary">
22- <span class="iconf text-3xl">󰡨</span>
23- </div>
24- <div class="stat-title">Containers</div>
25- <div class="stat-value" id="container-count">{{.ContainerCount}}</div>
26- <div class="stat-desc">Docker containers managed by Gordon</div>
27- </div>
28-
29- <div class="stat">
30- <div class="stat-figure text-secondary">
31- <span class="iconf text-3xl">󰋊</span>
32- </div>
33- <div class="stat-title">Images</div>
34- <div class="stat-value" id="image-count">{{.ImageCount}}</div>
35- <div class="stat-desc">Docker images available</div>
36- </div>
37- </div>
38-
39- <!-- Tabs for containers and images -->
40- <div role="tablist" class="tabs tabs-boxed mb-4">
41- <input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Containers" id="containers-tab" checked />
42- <div role="tabpanel" class="tab-content p-0">
43- <!-- Container manager section -->
44- <div id="container-manager"
45- hx-get="/htmx/container-manager"
46- hx-trigger="load"
47- hx-swap="innerHTML">
48- <!-- Container content loaded via HTMX -->
49- <div class="text-center p-4">
50- <span class="iconf text-3xl animate-spin inline-block">󰑐</span>
51- Loading containers...
52- </div>
53- </div>
54- </div>
55-
56- <input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Images" id="images-tab" />
57- <div role="tabpanel" class="tab-content p-0">
58- <!-- Image manager section -->
59- <div id="image-manager"
60- hx-get="/htmx/image-manager"
61- hx-trigger="load"
62- hx-swap="innerHTML">
63- <!-- Image content loaded via HTMX -->
64- <div class="text-center p-4">
65- <span class="iconf text-3xl animate-spin inline-block">󰑐</span>
66- Loading images...
67- </div>
68- </div>
69- </div>
70- </div>
71-
72- </div>
73-
74- {{template "footer.gohtml" .}}
75- </body>
76-</html>
D internal/templating/models/html/admin/user.gohtml +0 -0

no textual change.

D internal/templating/models/html/fragments/components.gohtml +0 -76
@@ -1,76 +0,0 @@
1-<!-- @format -->
2-
3-<div id="header" class="hero bg-base-200">
4- <div class="hero-content text-center">
5- <div class="max-w-md">
6- <h1 class="text-5xl font-bold gordon-title">
7- {{ .body.header.helloworld }}
8- </h1>
9- <p class="py-6 IBMPlexSans">Welcome to Gordon Container Management system.</p>
10- <button class="btn btn-primary">Get Started</button>
11- </div>
12- </div>
13-</div>
14-
15-<div id="hello" class="alert alert-info shadow-lg mt-4 mb-4">
16- <div>
17- <span class="iconfb">󰋽</span>
18- <span class="IBMPlexSans">{{ .body.div.hello }}</span>
19- </div>
20-</div>
21-
22-<div
23- id="header"
24- hx-get="/htmx"
25- hx-headers='{"HX-Fragment": "header"}'
26- hx-trigger="load"
27- hx-swap="outerHTML"
28-></div>
29-
30-<!-- App Fragment -->
31-<div
32- id="app"
33- hx-get="/htmx"
34- hx-headers='{"HX-Fragment": "hello"}'
35- hx-trigger="load"
36- hx-swap="outerHTML"
37-></div>
38-
39-<!-- Stats Cards -->
40-<div class="grid grid-cols-1 md:grid-cols-3 gap-4 my-4">
41- <div class="stat bg-base-100 shadow">
42- <div class="stat-figure text-primary">
43- <span class="iconfb text-3xl">󰡨</span>
44- </div>
45- <div class="stat-title">Containers</div>
46- <div class="stat-value">31</div>
47- <div class="stat-desc">Running containers</div>
48- </div>
49-
50- <div class="stat bg-base-100 shadow">
51- <div class="stat-figure text-secondary">
52- <span class="iconfb text-3xl">󱧕</span>
53- </div>
54- <div class="stat-title">Images</div>
55- <div class="stat-value">42</div>
56- <div class="stat-desc">Available images</div>
57- </div>
58-
59- <div class="stat bg-base-100 shadow">
60- <div class="stat-figure text-accent">
61- <span class="iconfb text-3xl">󰙅</span>
62- </div>
63- <div class="stat-title">Networks</div>
64- <div class="stat-value">12</div>
65- <div class="stat-desc">Container networks</div>
66- </div>
67-</div>
68-
69-<!-- Example of tabs -->
70-<div class="tabs tabs-boxed my-4">
71- <a class="tab tab-active">Overview</a>
72- <a class="tab">Containers</a>
73- <a class="tab">Images</a>
74- <a class="tab">Networks</a>
75- <a class="tab">Volumes</a>
76-</div>
@@ -1,76 +0,0 @@
1-<!-- @format -->
2-
3-<div id="header" class="hero bg-base-200">
4- <div class="hero-content text-center">
5- <div class="max-w-md">
6- <h1 class="text-5xl font-bold gordon-title">
7- {{ .body.header.helloworld }}
8- </h1>
9- <p class="py-6 IBMPlexSans">Welcome to Gordon Container Management system.</p>
10- <button class="btn btn-primary">Get Started</button>
11- </div>
12- </div>
13-</div>
14-
15-<div id="hello" class="alert alert-info shadow-lg mt-4 mb-4">
16- <div>
17- <span class="iconfb">󰋽</span>
18- <span class="IBMPlexSans">{{ .body.div.hello }}</span>
19- </div>
20-</div>
21-
22-<div
23- id="header"
24- hx-get="/htmx"
25- hx-headers='{"HX-Fragment": "header"}'
26- hx-trigger="load"
27- hx-swap="outerHTML"
28-></div>
29-
30-<!-- App Fragment -->
31-<div
32- id="app"
33- hx-get="/htmx"
34- hx-headers='{"HX-Fragment": "hello"}'
35- hx-trigger="load"
36- hx-swap="outerHTML"
37-></div>
38-
39-<!-- Stats Cards -->
40-<div class="grid grid-cols-1 md:grid-cols-3 gap-4 my-4">
41- <div class="stat bg-base-100 shadow">
42- <div class="stat-figure text-primary">
43- <span class="iconfb text-3xl">󰡨</span>
44- </div>
45- <div class="stat-title">Containers</div>
46- <div class="stat-value">31</div>
47- <div class="stat-desc">Running containers</div>
48- </div>
49-
50- <div class="stat bg-base-100 shadow">
51- <div class="stat-figure text-secondary">
52- <span class="iconfb text-3xl">󱧕</span>
53- </div>
54- <div class="stat-title">Images</div>
55- <div class="stat-value">42</div>
56- <div class="stat-desc">Available images</div>
57- </div>
58-
59- <div class="stat bg-base-100 shadow">
60- <div class="stat-figure text-accent">
61- <span class="iconfb text-3xl">󰙅</span>
62- </div>
63- <div class="stat-title">Networks</div>
64- <div class="stat-value">12</div>
65- <div class="stat-desc">Container networks</div>
66- </div>
67-</div>
68-
69-<!-- Example of tabs -->
70-<div class="tabs tabs-boxed my-4">
71- <a class="tab tab-active">Overview</a>
72- <a class="tab">Containers</a>
73- <a class="tab">Images</a>
74- <a class="tab">Networks</a>
75- <a class="tab">Volumes</a>
76-</div>
D internal/templating/models/html/fragments/containerlist.gohtml +0 -122
@@ -1,122 +0,0 @@
1-<!-- Container list using daisyUI components -->
2-<div class="card w-full bg-base-300">
3- <div class="card-body p-4">
4-
5- <!-- Action buttons -->
6- <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4">
7- <h3 class="font-semibold text-base-content">Available Containers: {{ len .Containers }}</h3>
8- <div class="btn-group">
9- <button
10- class="btn btn-sm btn-primary"
11- hx-get="/htmx/container-manager"
12- hx-target="#container-manager"
13- hx-swap="innerHTML">
14- <span class="iconf">󰑓</span> Refresh
15- </button>
16- </div>
17- </div>
18-
19- <!-- Container list -->
20- <table class="table">
21- <!-- head -->
22- <thead>
23- <tr>
24- <th>Name</th>
25- <th>Image</th>
26- <th>Status</th>
27- <th>Created</th>
28- <th>Actions</th>
29- </tr>
30- </thead>
31- <tbody>
32- {{ if or .NoContainer (eq (len .Containers) 0) }}
33- <tr>
34- <td colspan="5" class="text-center py-4">
35- <div class="alert alert-info">
36- <span class="iconf">ℹ️</span>
37- <span>No containers found. Upload an image first, then create a container.</span>
38- </div>
39- </td>
40- </tr>
41- {{ else }}
42- {{ range .Containers }}
43- <tr class="hover">
44- <td>
45- <div class="flex items-center gap-3">
46- <div>
47- <div class="font-bold">{{ .Names }}</div>
48- <div class="text-sm opacity-50">{{ .ID }}</div>
49- </div>
50- </div>
51- </td>
52- <td>{{ .Image }}</td>
53- <td>
54- {{ if eq .State "running" }}
55- <div class="badge badge-success badge-sm">Running</div>
56- {{ else if eq .State "exited" }}
57- <div class="badge badge-warning badge-sm">Stopped</div>
58- {{ else }}
59- <div class="badge badge-ghost badge-sm">{{ .State }}</div>
60- {{ end }}
61- </td>
62- <td>{{ .Created }}</td>
63- <th>
64- <div class="dropdown dropdown-end">
65- <div tabindex="0" role="button" class="btn btn-ghost btn-xs">
66- <span class="iconf">󰅀</span>
67- </div>
68- <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-40">
69- {{ if eq .State "running" }}
70- <li>
71- <button
72- class="text-warning flex items-center"
73- hx-post="/htmx/container-manager/stop/{{ .ID }}"
74- hx-target="#container-manager"
75- hx-swap="innerHTML">
76- <span class="iconf mr-2">󰓛</span> Stop
77- </button>
78- </li>
79- {{ else if eq .State "exited" }}
80- <li>
81- <button
82- class="text-success flex items-center"
83- hx-post="/htmx/container-manager/start/{{ .ID }}"
84- hx-target="#container-manager"
85- hx-swap="innerHTML">
86- <span class="iconf mr-2">󰐊</span> Start
87- </button>
88- </li>
89- {{ end }}
90- <li>
91- <button
92- class="text-info flex items-center"
93- hx-get="/htmx/container-manager/edit/{{ .ID }}"
94- hx-target="#container-manager"
95- hx-swap="innerHTML">
96- <span class="iconf mr-2">󰏫</span> Edit
97- </button>
98- </li>
99- <li>
100- <button
101- class="text-error flex items-center"
102- hx-delete="/htmx/container-manager/delete/{{ .ID }}"
103- hx-target="#container-manager"
104- hx-swap="innerHTML"
105- hx-confirm="Are you sure you want to delete this container?">
106- <span class="iconf mr-2">󰆴</span> Delete
107- </button>
108- </li>
109- </ul>
110- </div>
111- </th>
112- </tr>
113- {{ end }}
114- {{ end }}
115- </tbody>
116- </table>
117- </div>
118-</div>
119-
120-<script>
121-console.debug('Container list rendered');
122-</script>
@@ -1,122 +0,0 @@
1-<!-- Container list using daisyUI components -->
2-<div class="card w-full bg-base-300">
3- <div class="card-body p-4">
4-
5- <!-- Action buttons -->
6- <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4">
7- <h3 class="font-semibold text-base-content">Available Containers: {{ len .Containers }}</h3>
8- <div class="btn-group">
9- <button
10- class="btn btn-sm btn-primary"
11- hx-get="/htmx/container-manager"
12- hx-target="#container-manager"
13- hx-swap="innerHTML">
14- <span class="iconf">󰑓</span> Refresh
15- </button>
16- </div>
17- </div>
18-
19- <!-- Container list -->
20- <table class="table">
21- <!-- head -->
22- <thead>
23- <tr>
24- <th>Name</th>
25- <th>Image</th>
26- <th>Status</th>
27- <th>Created</th>
28- <th>Actions</th>
29- </tr>
30- </thead>
31- <tbody>
32- {{ if or .NoContainer (eq (len .Containers) 0) }}
33- <tr>
34- <td colspan="5" class="text-center py-4">
35- <div class="alert alert-info">
36- <span class="iconf">ℹ️</span>
37- <span>No containers found. Upload an image first, then create a container.</span>
38- </div>
39- </td>
40- </tr>
41- {{ else }}
42- {{ range .Containers }}
43- <tr class="hover">
44- <td>
45- <div class="flex items-center gap-3">
46- <div>
47- <div class="font-bold">{{ .Names }}</div>
48- <div class="text-sm opacity-50">{{ .ID }}</div>
49- </div>
50- </div>
51- </td>
52- <td>{{ .Image }}</td>
53- <td>
54- {{ if eq .State "running" }}
55- <div class="badge badge-success badge-sm">Running</div>
56- {{ else if eq .State "exited" }}
57- <div class="badge badge-warning badge-sm">Stopped</div>
58- {{ else }}
59- <div class="badge badge-ghost badge-sm">{{ .State }}</div>
60- {{ end }}
61- </td>
62- <td>{{ .Created }}</td>
63- <th>
64- <div class="dropdown dropdown-end">
65- <div tabindex="0" role="button" class="btn btn-ghost btn-xs">
66- <span class="iconf">󰅀</span>
67- </div>
68- <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-40">
69- {{ if eq .State "running" }}
70- <li>
71- <button
72- class="text-warning flex items-center"
73- hx-post="/htmx/container-manager/stop/{{ .ID }}"
74- hx-target="#container-manager"
75- hx-swap="innerHTML">
76- <span class="iconf mr-2">󰓛</span> Stop
77- </button>
78- </li>
79- {{ else if eq .State "exited" }}
80- <li>
81- <button
82- class="text-success flex items-center"
83- hx-post="/htmx/container-manager/start/{{ .ID }}"
84- hx-target="#container-manager"
85- hx-swap="innerHTML">
86- <span class="iconf mr-2">󰐊</span> Start
87- </button>
88- </li>
89- {{ end }}
90- <li>
91- <button
92- class="text-info flex items-center"
93- hx-get="/htmx/container-manager/edit/{{ .ID }}"
94- hx-target="#container-manager"
95- hx-swap="innerHTML">
96- <span class="iconf mr-2">󰏫</span> Edit
97- </button>
98- </li>
99- <li>
100- <button
101- class="text-error flex items-center"
102- hx-delete="/htmx/container-manager/delete/{{ .ID }}"
103- hx-target="#container-manager"
104- hx-swap="innerHTML"
105- hx-confirm="Are you sure you want to delete this container?">
106- <span class="iconf mr-2">󰆴</span> Delete
107- </button>
108- </li>
109- </ul>
110- </div>
111- </th>
112- </tr>
113- {{ end }}
114- {{ end }}
115- </tbody>
116- </table>
117- </div>
118-</div>
119-
120-<script>
121-console.debug('Container list rendered');
122-</script>
D internal/templating/models/html/fragments/createcontainer.gohtml +0 -140
@@ -1,140 +0,0 @@
1-<div class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow" hx-ext="response-targets">
2- <!-- Left Side: Docker Run Options -->
3- <div class="flex-grow card p-6">
4- <h2 class="card-title pb-4">Deploy a container based on {{.ImageName}}</h2>
5- {{if .ErrorMessage}}
6- <div class="alert alert-error">
7- <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
8- <span>{{.ErrorMessage}}</span>
9- </div>
10- {{ end }}
11- <form id="create-container-form-{{.ShortID}}" action="/create-container/{{.ShortID}}" method="post"
12- hx-post="/htmx/create-container/{{.ShortID}}"
13- hx-swap="innerHTML"
14- hx-target="#create-container-{{.ShortID}}"
15- hx-target-*="#error-message-create-container-{{.ShortID}}">
16- <!-- Image Name -->
17- <div class="grid grid-cols-1 md:grid-cols-3 gap-4 pb-4">
18- <div class="md:col-span-1">
19- <label class="label">
20- <span class="label-text">Image name</span>
21- </label>
22- <input type="text" name="image_name" class="input input-bordered input-disabled w-full"
23- value="{{.ImageName}}" readonly />
24- </div>
25-
26- <div class="md:col-span-2">
27- <label class="label">
28- <span class="label-text">Image ID</span>
29- </label>
30- <input type="text" name="image_id" class="input input-bordered input-disabled w-full"
31- value="{{.ImageID}}" readonly />
32- </div>
33- </div>
34-
35- <!-- Container Name -->
36- <div class="form-control pb-4">
37- <label class="label">
38- <span class="label-text">Container name</span>
39- </label>
40- <input type="text" name="container_name" class="input input-bordered w-full" value="{{.ContainerName}}" />
41- </div>
42-
43- <!-- Http(s) / Subdomain / Domain -->
44- <div class="grid grid-cols-1 md:grid-cols-12 gap-4 pb-4">
45- <!-- Protocol -->
46- <div class="md:col-span-3">
47- <label class="label">
48- <span class="label-text">Protocol</span>
49- </label>
50- <select name="container_protocol" class="select select-bordered w-full">
51- <option value="https">https</option>
52- <option value="http">http</option>
53- </select>
54- </div>
55-
56- <!-- Subdomain -->
57- <div class="md:col-span-4">
58- <label class="label">
59- <span class="label-text">Subdomain</span>
60- </label>
61- <input type="text" name="container_subdomain" class="input input-bordered w-full" value="{{.ContainerSubdomain}}" />
62- </div>
63-
64- <!-- Domain -->
65- <div class="md:col-span-5">
66- <label class="label">
67- <span class="label-text">Domain</span>
68- </label>
69- <input type="text" name="container_domain" class="input input-bordered w-full" value="{{.ContainerDomain}}" />
70- </div>
71- </div>
72-
73- <!-- Exposed Ports -->
74- <div class="form-control pb-4">
75- <label class="label">
76- <span class="label-text">Exposed Ports (Optional)</span>
77- <span class="label-text-alt">eg: 8080:1887/tcp (You can specify multiple ports by separating them with a comma)</span>
78- </label>
79- <input type="text" name="ports" class="input input-bordered w-full" value="{{.Ports}}" />
80- </div>
81-
82- <!-- Proxy Port -->
83- <div class="form-control pb-4">
84- <label class="label">
85- <span class="label-text">Container Proxy Port (Required)</span>
86- <span class="label-text-alt">Specify the internal container port that the proxy will route traffic to</span>
87- </label>
88- <input type="text" name="container_port" class="input input-bordered w-full" value="{{.ProxyPort}}" required />
89- </div>
90-
91- <!-- Volumes -->
92- <div class="form-control pb-4">
93- <label class="label">
94- <span class="label-text">Volumes</span>
95- <span class="label-text-alt">e.g. ./data:/container/data</span>
96- </label>
97- <input type="text" name="volumes" class="input input-bordered w-full" value="{{.Volumes}}" />
98- </div>
99-
100- <div class="form-control pb-4">
101- <label class="label">
102- <span class="label-text">Environment variables</span>
103- <span class="label-text-alt">e.g. KEY=VALUE</span>
104- </label>
105- <textarea name="environment_variables" class="textarea textarea-bordered w-full"
106- rows="4">{{.EnvironmentVariables}}</textarea>
107- </div>
108-
109- <!-- Restart: always/on-failure/only when stopped/never -->
110- <div class="form-control pb-4">
111- <label class="label">
112- <span class="label-text">Restart</span>
113- </label>
114- <select name="restart" class="select select-bordered w-full">
115- <option value="always">Always</option>
116- <option value="on-failure">On failure</option>
117- <option value="unless-stopped">Unless stopped</option>
118- <option value="never">Never</option>
119- </select>
120- </div>
121-
122- <!-- Skip Proxy Setup Option -->
123- <div class="form-control pb-4">
124- <label class="label cursor-pointer justify-start gap-4">
125- <input type="checkbox" name="skip_proxy_setup" value="true" class="checkbox checkbox-primary" />
126- <span>Skip proxy setup (create container only, don't set up domain/certificates)</span>
127- </label>
128- <div class="text-sm opacity-70 ml-10">
129- Use this option if you only want to create the container without triggering proxy setup,
130- certificate generation, and other related processes.
131- </div>
132- </div>
133-
134- <button type="submit" id="create-container-submit" class="btn btn-primary">
135- Create Container
136- </button>
137- </form>
138- <div id="error-message-create-container-{{.ShortID}}" class="text-error mt-4"></div>
139- </div>
140-</div>⏎̸
@@ -1,140 +0,0 @@
1-<div class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow" hx-ext="response-targets">
2- <!-- Left Side: Docker Run Options -->
3- <div class="flex-grow card p-6">
4- <h2 class="card-title pb-4">Deploy a container based on {{.ImageName}}</h2>
5- {{if .ErrorMessage}}
6- <div class="alert alert-error">
7- <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
8- <span>{{.ErrorMessage}}</span>
9- </div>
10- {{ end }}
11- <form id="create-container-form-{{.ShortID}}" action="/create-container/{{.ShortID}}" method="post"
12- hx-post="/htmx/create-container/{{.ShortID}}"
13- hx-swap="innerHTML"
14- hx-target="#create-container-{{.ShortID}}"
15- hx-target-*="#error-message-create-container-{{.ShortID}}">
16- <!-- Image Name -->
17- <div class="grid grid-cols-1 md:grid-cols-3 gap-4 pb-4">
18- <div class="md:col-span-1">
19- <label class="label">
20- <span class="label-text">Image name</span>
21- </label>
22- <input type="text" name="image_name" class="input input-bordered input-disabled w-full"
23- value="{{.ImageName}}" readonly />
24- </div>
25-
26- <div class="md:col-span-2">
27- <label class="label">
28- <span class="label-text">Image ID</span>
29- </label>
30- <input type="text" name="image_id" class="input input-bordered input-disabled w-full"
31- value="{{.ImageID}}" readonly />
32- </div>
33- </div>
34-
35- <!-- Container Name -->
36- <div class="form-control pb-4">
37- <label class="label">
38- <span class="label-text">Container name</span>
39- </label>
40- <input type="text" name="container_name" class="input input-bordered w-full" value="{{.ContainerName}}" />
41- </div>
42-
43- <!-- Http(s) / Subdomain / Domain -->
44- <div class="grid grid-cols-1 md:grid-cols-12 gap-4 pb-4">
45- <!-- Protocol -->
46- <div class="md:col-span-3">
47- <label class="label">
48- <span class="label-text">Protocol</span>
49- </label>
50- <select name="container_protocol" class="select select-bordered w-full">
51- <option value="https">https</option>
52- <option value="http">http</option>
53- </select>
54- </div>
55-
56- <!-- Subdomain -->
57- <div class="md:col-span-4">
58- <label class="label">
59- <span class="label-text">Subdomain</span>
60- </label>
61- <input type="text" name="container_subdomain" class="input input-bordered w-full" value="{{.ContainerSubdomain}}" />
62- </div>
63-
64- <!-- Domain -->
65- <div class="md:col-span-5">
66- <label class="label">
67- <span class="label-text">Domain</span>
68- </label>
69- <input type="text" name="container_domain" class="input input-bordered w-full" value="{{.ContainerDomain}}" />
70- </div>
71- </div>
72-
73- <!-- Exposed Ports -->
74- <div class="form-control pb-4">
75- <label class="label">
76- <span class="label-text">Exposed Ports (Optional)</span>
77- <span class="label-text-alt">eg: 8080:1887/tcp (You can specify multiple ports by separating them with a comma)</span>
78- </label>
79- <input type="text" name="ports" class="input input-bordered w-full" value="{{.Ports}}" />
80- </div>
81-
82- <!-- Proxy Port -->
83- <div class="form-control pb-4">
84- <label class="label">
85- <span class="label-text">Container Proxy Port (Required)</span>
86- <span class="label-text-alt">Specify the internal container port that the proxy will route traffic to</span>
87- </label>
88- <input type="text" name="container_port" class="input input-bordered w-full" value="{{.ProxyPort}}" required />
89- </div>
90-
91- <!-- Volumes -->
92- <div class="form-control pb-4">
93- <label class="label">
94- <span class="label-text">Volumes</span>
95- <span class="label-text-alt">e.g. ./data:/container/data</span>
96- </label>
97- <input type="text" name="volumes" class="input input-bordered w-full" value="{{.Volumes}}" />
98- </div>
99-
100- <div class="form-control pb-4">
101- <label class="label">
102- <span class="label-text">Environment variables</span>
103- <span class="label-text-alt">e.g. KEY=VALUE</span>
104- </label>
105- <textarea name="environment_variables" class="textarea textarea-bordered w-full"
106- rows="4">{{.EnvironmentVariables}}</textarea>
107- </div>
108-
109- <!-- Restart: always/on-failure/only when stopped/never -->
110- <div class="form-control pb-4">
111- <label class="label">
112- <span class="label-text">Restart</span>
113- </label>
114- <select name="restart" class="select select-bordered w-full">
115- <option value="always">Always</option>
116- <option value="on-failure">On failure</option>
117- <option value="unless-stopped">Unless stopped</option>
118- <option value="never">Never</option>
119- </select>
120- </div>
121-
122- <!-- Skip Proxy Setup Option -->
123- <div class="form-control pb-4">
124- <label class="label cursor-pointer justify-start gap-4">
125- <input type="checkbox" name="skip_proxy_setup" value="true" class="checkbox checkbox-primary" />
126- <span>Skip proxy setup (create container only, don't set up domain/certificates)</span>
127- </label>
128- <div class="text-sm opacity-70 ml-10">
129- Use this option if you only want to create the container without triggering proxy setup,
130- certificate generation, and other related processes.
131- </div>
132- </div>
133-
134- <button type="submit" id="create-container-submit" class="btn btn-primary">
135- Create Container
136- </button>
137- </form>
138- <div id="error-message-create-container-{{.ShortID}}" class="text-error mt-4"></div>
139- </div>
140-</div>⏎̸
D internal/templating/models/html/fragments/editcontainer.gohtml +0 -32
@@ -1,32 +0,0 @@
1-<div
2- id="edit-container-{{.ID}}"
3- class="card bg-base-100 shadow-xl"
4- hx-ext="response-targets"
5->
6- <div id="editor" class="card-body">
7- <form
8- method="post"
9- action="/htmx/container-manager/edit/{{.ID}}"
10- hx-post="/htmx/container-manager/edit/{{.ID}}"
11- hx-swap="innerHTML"
12- hx-target-*="#error-message-edit-container-{{.ID}}"
13- >
14- <textarea
15- name="container_config"
16- id="container_config"
17- class="textarea textarea-bordered w-full h-96 font-mono JetBrainsMonoNF"
18- rows="20"
19- >{{.ContainerInfo}}</textarea
20- >
21- <div class="card-actions justify-end mt-4">
22- <input
23- type="submit"
24- value="Save"
25- id="save-button-{{.ID}}"
26- class="btn btn-primary"
27- />
28- </div>
29- </form>
30- <div id="error-message-edit-container-{{.ID}}" class="text-error mt-2"></div>
31- </div>
32-</div>
@@ -1,32 +0,0 @@
1-<div
2- id="edit-container-{{.ID}}"
3- class="card bg-base-100 shadow-xl"
4- hx-ext="response-targets"
5->
6- <div id="editor" class="card-body">
7- <form
8- method="post"
9- action="/htmx/container-manager/edit/{{.ID}}"
10- hx-post="/htmx/container-manager/edit/{{.ID}}"
11- hx-swap="innerHTML"
12- hx-target-*="#error-message-edit-container-{{.ID}}"
13- >
14- <textarea
15- name="container_config"
16- id="container_config"
17- class="textarea textarea-bordered w-full h-96 font-mono JetBrainsMonoNF"
18- rows="20"
19- >{{.ContainerInfo}}</textarea
20- >
21- <div class="card-actions justify-end mt-4">
22- <input
23- type="submit"
24- value="Save"
25- id="save-button-{{.ID}}"
26- class="btn btn-primary"
27- />
28- </div>
29- </form>
30- <div id="error-message-edit-container-{{.ID}}" class="text-error mt-2"></div>
31- </div>
32-</div>
D internal/templating/models/html/fragments/footer.gohtml +0 -33
@@ -1,33 +0,0 @@
1-<footer class="footer p-10 bg-base-200 text-base-content">
2- <div>
3- <span class="footer-title">Gordon</span>
4- <a class="link link-hover">About us</a>
5- <a class="link link-hover">Documentation</a>
6- <a class="link link-hover">Github</a>
7- </div>
8- <div>
9- <span class="footer-title">Container Management</span>
10- <a class="link link-hover">Dashboard</a>
11- <a class="link link-hover">Settings</a>
12- <a class="link link-hover">Stats</a>
13- </div>
14- <div>
15- <span class="footer-title">Legal</span>
16- <a class="link link-hover">Terms of use</a>
17- <a class="link link-hover">Privacy policy</a>
18- <a class="link link-hover">License</a>
19- </div>
20-</footer>
21-<footer class="footer px-10 py-4 border-t bg-base-300 text-base-content border-base-300">
22- <div class="items-center grid-flow-col">
23- <span class="iconfb text-2xl">󰊢</span>
24- <p class="IBMPlexSans">Gordon Container Manager<br/>Providing reliable container management since 2023</p>
25- </div>
26- <div class="md:place-self-center md:justify-self-end">
27- <div class="grid grid-flow-col gap-4">
28- <a class="iconfb text-2xl">󰊤</a>
29- <a class="iconfb text-2xl">󰗃</a>
30- <a class="iconfb text-2xl">󰖟</a>
31- </div>
32- </div>
33-</footer>
@@ -1,33 +0,0 @@
1-<footer class="footer p-10 bg-base-200 text-base-content">
2- <div>
3- <span class="footer-title">Gordon</span>
4- <a class="link link-hover">About us</a>
5- <a class="link link-hover">Documentation</a>
6- <a class="link link-hover">Github</a>
7- </div>
8- <div>
9- <span class="footer-title">Container Management</span>
10- <a class="link link-hover">Dashboard</a>
11- <a class="link link-hover">Settings</a>
12- <a class="link link-hover">Stats</a>
13- </div>
14- <div>
15- <span class="footer-title">Legal</span>
16- <a class="link link-hover">Terms of use</a>
17- <a class="link link-hover">Privacy policy</a>
18- <a class="link link-hover">License</a>
19- </div>
20-</footer>
21-<footer class="footer px-10 py-4 border-t bg-base-300 text-base-content border-base-300">
22- <div class="items-center grid-flow-col">
23- <span class="iconfb text-2xl">󰊢</span>
24- <p class="IBMPlexSans">Gordon Container Manager<br/>Providing reliable container management since 2023</p>
25- </div>
26- <div class="md:place-self-center md:justify-self-end">
27- <div class="grid grid-flow-col gap-4">
28- <a class="iconfb text-2xl">󰊤</a>
29- <a class="iconfb text-2xl">󰗃</a>
30- <a class="iconfb text-2xl">󰖟</a>
31- </div>
32- </div>
33-</footer>
D internal/templating/models/html/fragments/header.gohtml +0 -24
@@ -1,24 +0,0 @@
1-<head>
2- <meta charset="UTF-8" />
3- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
4- <title>{{ .header.meta.title }}</title>
5- <link rel="stylesheet" href="/assets/css/tailwind.css" />
6-
7- <script src="/libs/jquery.min.js"></script>
8- <script src="/libs/htmx.min.js"></script>
9- <script src="/libs/htmx-response-targets.js"></script>
10- <script src="/libs/htmx-debug.js"></script>
11- <script src="/libs/htmx-sse.js"></script>
12- <script defer src="/libs/hyperscript.min.js"></script>
13- <script defer src="/assets/js/custom.js"></script>
14-
15- <!-- daisyUI theme configuration -->
16- <script>
17- // Check for saved theme preference or use preferred color scheme
18- const savedTheme = localStorage.getItem('theme') || 'gordon';
19- document.documentElement.setAttribute('data-theme', savedTheme);
20-
21- // Log theme loading for debugging
22- console.debug('Theme loaded:', savedTheme);
23- </script>
24-</head>
@@ -1,24 +0,0 @@
1-<head>
2- <meta charset="UTF-8" />
3- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
4- <title>{{ .header.meta.title }}</title>
5- <link rel="stylesheet" href="/assets/css/tailwind.css" />
6-
7- <script src="/libs/jquery.min.js"></script>
8- <script src="/libs/htmx.min.js"></script>
9- <script src="/libs/htmx-response-targets.js"></script>
10- <script src="/libs/htmx-debug.js"></script>
11- <script src="/libs/htmx-sse.js"></script>
12- <script defer src="/libs/hyperscript.min.js"></script>
13- <script defer src="/assets/js/custom.js"></script>
14-
15- <!-- daisyUI theme configuration -->
16- <script>
17- // Check for saved theme preference or use preferred color scheme
18- const savedTheme = localStorage.getItem('theme') || 'gordon';
19- document.documentElement.setAttribute('data-theme', savedTheme);
20-
21- // Log theme loading for debugging
22- console.debug('Theme loaded:', savedTheme);
23- </script>
24-</head>
D internal/templating/models/html/fragments/imagelist.gohtml +0 -98
@@ -1,98 +0,0 @@
1-<!-- Image list using daisyUI components -->
2-<div class="card w-full bg-base-300">
3- <div class="card-body p-4">
4-
5- <!-- Action buttons -->
6- <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4">
7- <h3 class="font-semibold text-base-content">Available Images: {{ len .Images }}</h3>
8- <div class="flex gap-2">
9- <button
10- class="btn btn-sm btn-secondary"
11- hx-get="/htmx/upload-image"
12- hx-target="#image-manager"
13- hx-swap="innerHTML">
14- <span class="iconf">󰦒</span> Upload
15- </button>
16- <button
17- class="btn btn-sm btn-primary"
18- hx-get="/htmx/image-manager"
19- hx-target="#image-manager"
20- hx-swap="innerHTML">
21- <span class="iconf">󰑓</span> Refresh
22- </button>
23- </div>
24- </div>
25-
26- <!-- Image list -->
27- <table class="table">
28- <!-- head -->
29- <thead>
30- <tr>
31- <th>Repository</th>
32- <th>Tag</th>
33- <th>Size</th>
34- <th>Created</th>
35- <th>Actions</th>
36- </tr>
37- </thead>
38- <tbody>
39- {{ if .NoImage }}
40- <tr>
41- <td colspan="5" class="text-center py-4">
42- <div class="alert alert-info">
43- <span class="iconf">ℹ️</span>
44- <span>No images found. Upload an image first.</span>
45- </div>
46- </td>
47- </tr>
48- {{ else }}
49- {{ range .Images }}
50- <tr class="hover">
51- <td>
52- <div class="flex items-center gap-3">
53- <div>
54- <div class="font-bold">{{ .Repository }}</div>
55- <div class="text-sm opacity-50">{{ .ID }}</div>
56- </div>
57- </div>
58- </td>
59- <td>{{ .Tag }}</td>
60- <td>{{ .Size }}</td>
61- <td>{{ .Created }}</td>
62- <th>
63- <div class="dropdown dropdown-end">
64- <div tabindex="0" role="button" class="btn btn-ghost btn-xs">
65- <span class="iconf">󰅀</span>
66- </div>
67- <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-52">
68- <li>
69- <a
70- href="/htmx/create-container/{{ if gt (len .ID) 12 }}{{ slice .ID 0 12 }}{{ else }}{{ .ID }}{{ end }}"
71- class="text-success flex items-center">
72- <span class="iconf mr-2">󰐕</span> Create Container
73- </a>
74- </li>
75- <li>
76- <button
77- class="text-error flex items-center"
78- hx-delete="/htmx/image-manager/delete/{{ .ID }}"
79- hx-target="#image-manager"
80- hx-swap="innerHTML"
81- hx-confirm="Are you sure you want to delete this image?">
82- <span class="iconf mr-2">󰆴</span> Delete
83- </button>
84- </li>
85- </ul>
86- </div>
87- </th>
88- </tr>
89- {{ end }}
90- {{ end }}
91- </tbody>
92- </table>
93- </div>
94-</div>
95-
96-<script>
97-console.debug('Image list rendered');
98-</script>
@@ -1,98 +0,0 @@
1-<!-- Image list using daisyUI components -->
2-<div class="card w-full bg-base-300">
3- <div class="card-body p-4">
4-
5- <!-- Action buttons -->
6- <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4">
7- <h3 class="font-semibold text-base-content">Available Images: {{ len .Images }}</h3>
8- <div class="flex gap-2">
9- <button
10- class="btn btn-sm btn-secondary"
11- hx-get="/htmx/upload-image"
12- hx-target="#image-manager"
13- hx-swap="innerHTML">
14- <span class="iconf">󰦒</span> Upload
15- </button>
16- <button
17- class="btn btn-sm btn-primary"
18- hx-get="/htmx/image-manager"
19- hx-target="#image-manager"
20- hx-swap="innerHTML">
21- <span class="iconf">󰑓</span> Refresh
22- </button>
23- </div>
24- </div>
25-
26- <!-- Image list -->
27- <table class="table">
28- <!-- head -->
29- <thead>
30- <tr>
31- <th>Repository</th>
32- <th>Tag</th>
33- <th>Size</th>
34- <th>Created</th>
35- <th>Actions</th>
36- </tr>
37- </thead>
38- <tbody>
39- {{ if .NoImage }}
40- <tr>
41- <td colspan="5" class="text-center py-4">
42- <div class="alert alert-info">
43- <span class="iconf">ℹ️</span>
44- <span>No images found. Upload an image first.</span>
45- </div>
46- </td>
47- </tr>
48- {{ else }}
49- {{ range .Images }}
50- <tr class="hover">
51- <td>
52- <div class="flex items-center gap-3">
53- <div>
54- <div class="font-bold">{{ .Repository }}</div>
55- <div class="text-sm opacity-50">{{ .ID }}</div>
56- </div>
57- </div>
58- </td>
59- <td>{{ .Tag }}</td>
60- <td>{{ .Size }}</td>
61- <td>{{ .Created }}</td>
62- <th>
63- <div class="dropdown dropdown-end">
64- <div tabindex="0" role="button" class="btn btn-ghost btn-xs">
65- <span class="iconf">󰅀</span>
66- </div>
67- <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-52">
68- <li>
69- <a
70- href="/htmx/create-container/{{ if gt (len .ID) 12 }}{{ slice .ID 0 12 }}{{ else }}{{ .ID }}{{ end }}"
71- class="text-success flex items-center">
72- <span class="iconf mr-2">󰐕</span> Create Container
73- </a>
74- </li>
75- <li>
76- <button
77- class="text-error flex items-center"
78- hx-delete="/htmx/image-manager/delete/{{ .ID }}"
79- hx-target="#image-manager"
80- hx-swap="innerHTML"
81- hx-confirm="Are you sure you want to delete this image?">
82- <span class="iconf mr-2">󰆴</span> Delete
83- </button>
84- </li>
85- </ul>
86- </div>
87- </th>
88- </tr>
89- {{ end }}
90- {{ end }}
91- </tbody>
92- </table>
93- </div>
94-</div>
95-
96-<script>
97-console.debug('Image list rendered');
98-</script>
D internal/templating/models/html/fragments/menu.gohtml +0 -67
@@ -1,67 +0,0 @@
1-<!-- DaisyUI Navbar -->
2-<div class="navbar bg-base-100 shadow-lg fixed top-0 z-50">
3- <div class="navbar-start">
4- <div class="flex items-center">
5- <h1 class="gordon-title text-2xl md:text-3xl text-primary tracking-tighter px-4">
6- Gordon
7- </h1>
8- <span id="actual-version" class="text-xs md:text-sm pr-1 JetBrainsMonoNF text-base-content/70">
9- {{ .BuildVersion }}
10- </span>
11- <span hidden id="update-available" class="text-success text-xs md:text-sm JetBrainsMonoNF hidden" title="">
12- <a href="https://github.com/bnema/gordon/releases" class="link">󰨦</a>
13- </span>
14- </div>
15- </div>
16- <div class="navbar-center hidden lg:flex">
17- <ul class="menu menu-horizontal px-1">
18- <li>
19- <a href="{{ .AdminPath }}/manager" class="font-semibold">
20- <span class="JetBrainsMonoBoldNF pr-1">󰘦</span> Manager
21- </a>
22- </li>
23- </ul>
24- </div>
25- <div class="navbar-end">
26- <!-- Theme toggle -->
27- <div class="dropdown dropdown-end">
28- <label tabindex="0" class="btn btn-ghost btn-circle">
29- <span class="iconf text-lg md:text-xl">󰔎</span>
30- </label>
31- <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
32- <li><button onclick="setTheme('gordon')">Gordon Light</button></li>
33- <li><button onclick="setTheme('gordon-dark')">Gordon Dark</button></li>
34- <li><button onclick="setTheme('light')">Light</button></li>
35- <li><button onclick="setTheme('dark')">Dark</button></li>
36- </ul>
37- </div>
38-
39- <!-- User menu -->
40- <div class="dropdown dropdown-end">
41- <label tabindex="0" class="btn btn-ghost btn-circle">
42- <span class="iconf text-lg md:text-xl">󰀉</span>
43- </label>
44- <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
45- <li>
46- <a href="{{ .UserSettingsURL }}" title="User settings">
47- <span class="iconf">󰒓</span> Settings
48- </a>
49- </li>
50- <li>
51- <a href="{{ .LogoutURL }}" title="Logout">
52- <span class="iconf">󰍃</span> Logout
53- </a>
54- </li>
55- </ul>
56- </div>
57- </div>
58-</div>
59-
60-<!-- Theme toggle script -->
61-<script>
62- function setTheme(theme) {
63- document.documentElement.setAttribute('data-theme', theme);
64- localStorage.setItem('theme', theme);
65- console.debug('Theme changed to:', theme);
66- }
67-</script>
@@ -1,67 +0,0 @@
1-<!-- DaisyUI Navbar -->
2-<div class="navbar bg-base-100 shadow-lg fixed top-0 z-50">
3- <div class="navbar-start">
4- <div class="flex items-center">
5- <h1 class="gordon-title text-2xl md:text-3xl text-primary tracking-tighter px-4">
6- Gordon
7- </h1>
8- <span id="actual-version" class="text-xs md:text-sm pr-1 JetBrainsMonoNF text-base-content/70">
9- {{ .BuildVersion }}
10- </span>
11- <span hidden id="update-available" class="text-success text-xs md:text-sm JetBrainsMonoNF hidden" title="">
12- <a href="https://github.com/bnema/gordon/releases" class="link">󰨦</a>
13- </span>
14- </div>
15- </div>
16- <div class="navbar-center hidden lg:flex">
17- <ul class="menu menu-horizontal px-1">
18- <li>
19- <a href="{{ .AdminPath }}/manager" class="font-semibold">
20- <span class="JetBrainsMonoBoldNF pr-1">󰘦</span> Manager
21- </a>
22- </li>
23- </ul>
24- </div>
25- <div class="navbar-end">
26- <!-- Theme toggle -->
27- <div class="dropdown dropdown-end">
28- <label tabindex="0" class="btn btn-ghost btn-circle">
29- <span class="iconf text-lg md:text-xl">󰔎</span>
30- </label>
31- <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
32- <li><button onclick="setTheme('gordon')">Gordon Light</button></li>
33- <li><button onclick="setTheme('gordon-dark')">Gordon Dark</button></li>
34- <li><button onclick="setTheme('light')">Light</button></li>
35- <li><button onclick="setTheme('dark')">Dark</button></li>
36- </ul>
37- </div>
38-
39- <!-- User menu -->
40- <div class="dropdown dropdown-end">
41- <label tabindex="0" class="btn btn-ghost btn-circle">
42- <span class="iconf text-lg md:text-xl">󰀉</span>
43- </label>
44- <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
45- <li>
46- <a href="{{ .UserSettingsURL }}" title="User settings">
47- <span class="iconf">󰒓</span> Settings
48- </a>
49- </li>
50- <li>
51- <a href="{{ .LogoutURL }}" title="Logout">
52- <span class="iconf">󰍃</span> Logout
53- </a>
54- </li>
55- </ul>
56- </div>
57- </div>
58-</div>
59-
60-<!-- Theme toggle script -->
61-<script>
62- function setTheme(theme) {
63- document.documentElement.setAttribute('data-theme', theme);
64- localStorage.setItem('theme', theme);
65- console.debug('Theme changed to:', theme);
66- }
67-</script>
D internal/templating/models/html/fragments/success.gohtml +0 -5
@@ -1,5 +0,0 @@
1-<div
2- id="success"
3- class="flex text-indigo-600 content-center justify-center items-center w-full h-full">
4- <p class="text-2xl text-center">{{if .Message}}{{.Message}}{{else}}Success!{{end}}</p>
5-</div>
@@ -1,5 +0,0 @@
1-<div
2- id="success"
3- class="flex text-indigo-600 content-center justify-center items-center w-full h-full">
4- <p class="text-2xl text-center">{{if .Message}}{{.Message}}{{else}}Success!{{end}}</p>
5-</div>
D internal/templating/models/html/fragments/uploadimage.gohtml +0 -61
@@ -1,61 +0,0 @@
1-<!-- Upload image form using DaisyUI components -->
2-<div class="card bg-base-100 shadow">
3- <div class="card-body">
4- <div class="flex justify-between items-center">
5- <h2 class="card-title text-primary">Upload Image</h2>
6- <button
7- class="btn btn-sm btn-circle btn-ghost"
8- hx-get="/htmx/image-manager"
9- hx-target="#image-manager"
10- hx-swap="innerHTML">
11- <span class="iconf text-lg">✕</span>
12- </button>
13- </div>
14- <div class="divider"></div>
15-
16- <form
17- id="upload-form"
18- hx-encoding="multipart/form-data"
19- hx-post="/htmx/upload-image"
20- hx-target="#image-manager"
21- hx-swap="innerHTML"
22- class="space-y-4">
23-
24- <div class="alert alert-info shadow-lg mb-4">
25- <span class="iconf">ℹ️</span>
26- <div>
27- <p>This form allows you to upload a container image (.tar file) to Gordon.</p>
28- <p>After uploading, you can create and manage containers from this image.</p>
29- </div>
30- </div>
31-
32- <div class="form-control w-full">
33- <label class="label">
34- <span class="label-text">Select a container image file (.tar)</span>
35- </label>
36- <input
37- type="file"
38- name="imageFile"
39- class="file-input file-input-bordered w-full"
40- accept=".tar"
41- required />
42- </div>
43-
44- <div class="card-actions justify-end mt-4">
45- <button
46- class="btn btn-ghost"
47- hx-get="/htmx/image-manager"
48- hx-target="#image-manager"
49- hx-swap="innerHTML"
50- type="button">
51- Cancel
52- </button>
53- <button
54- class="btn btn-primary"
55- type="submit">
56- <span class="iconf mr-1">󰦒</span> Upload
57- </button>
58- </div>
59- </form>
60- </div>
61-</div>
@@ -1,61 +0,0 @@
1-<!-- Upload image form using DaisyUI components -->
2-<div class="card bg-base-100 shadow">
3- <div class="card-body">
4- <div class="flex justify-between items-center">
5- <h2 class="card-title text-primary">Upload Image</h2>
6- <button
7- class="btn btn-sm btn-circle btn-ghost"
8- hx-get="/htmx/image-manager"
9- hx-target="#image-manager"
10- hx-swap="innerHTML">
11- <span class="iconf text-lg">✕</span>
12- </button>
13- </div>
14- <div class="divider"></div>
15-
16- <form
17- id="upload-form"
18- hx-encoding="multipart/form-data"
19- hx-post="/htmx/upload-image"
20- hx-target="#image-manager"
21- hx-swap="innerHTML"
22- class="space-y-4">
23-
24- <div class="alert alert-info shadow-lg mb-4">
25- <span class="iconf">ℹ️</span>
26- <div>
27- <p>This form allows you to upload a container image (.tar file) to Gordon.</p>
28- <p>After uploading, you can create and manage containers from this image.</p>
29- </div>
30- </div>
31-
32- <div class="form-control w-full">
33- <label class="label">
34- <span class="label-text">Select a container image file (.tar)</span>
35- </label>
36- <input
37- type="file"
38- name="imageFile"
39- class="file-input file-input-bordered w-full"
40- accept=".tar"
41- required />
42- </div>
43-
44- <div class="card-actions justify-end mt-4">
45- <button
46- class="btn btn-ghost"
47- hx-get="/htmx/image-manager"
48- hx-target="#image-manager"
49- hx-swap="innerHTML"
50- type="button">
51- Cancel
52- </button>
53- <button
54- class="btn btn-primary"
55- type="submit">
56- <span class="iconf mr-1">󰦒</span> Upload
57- </button>
58- </div>
59- </form>
60- </div>
61-</div>
D internal/templating/models/html/login/index.gohtml +0 -122
@@ -1,122 +0,0 @@
1-<!doctype html>
2-<html lang="en" data-theme="corporate">
3- <head>
4- <meta charset="UTF-8" />
5- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6- <title>{{ .header.meta.title }}</title>
7- <meta name="description" content="{{ .Header.Meta.Description }}" />
8- <meta name="keywords" content="{{ .Header.Meta.Keywords }}" />
9- <!-- Custom CSS + Tailwind -->
10- <link rel="stylesheet" href="/assets/css/tailwind.css" />
11- <!-- HTMX min js -->
12- <script src="/libs/htmx.min.js"></script>
13- <!-- HTMX SSE -->
14- <script src="/libs/htmx-sse.js"></script>
15- <!-- DaisyUI -->
16- <script src="/libs/daisyui.js"></script>
17- <!-- Custom JS from assets/js -->
18- <script src="/assets/js/custom.js"></script>
19-
20- <!-- daisyUI theme configuration -->
21- <script>
22- // Check for saved theme preference or use preferred color scheme
23- const savedTheme = localStorage.getItem('theme') || 'corporate';
24- document.documentElement.setAttribute('data-theme', savedTheme);
25- console.debug('Theme loaded:', savedTheme);
26- </script>
27- </head>
28-
29- <body class="IBMPlexSans bg-base-200 min-h-screen flex flex-col items-center justify-center p-4">
30- <!-- Logo and Title -->
31- <div
32- id="logo-mascot"
33- class="flex flex-col justify-center items-center p-4 mb-4">
34- <div class="flex justify-center w-100">
35- <img
36- src="/assets/imgs/gordon-mascot-mq-trsp.png"
37- alt="logo gordon"
38- class="sm:h-24 h-32 xl:h-48 drop-shadow-xl" />
39- </div>
40- <div class="flex justify-center mt-4">
41- <h1
42- class="gordon-title text-4xl lg:text-4xl xl:text-5xl text-primary tracking-tighter">
43- Gordon
44- </h1>
45- <span
46- class="gordon-v-subtitle text-secondary text-sm ml-2"
47- >{{ .BuildVersion }}</span
48- >
49- </div>
50- </div>
51-
52- <!-- Main Content -->
53- <div class="card bg-base-100 shadow-xl w-full max-w-md">
54- <div class="card-body">
55- <!-- Login Section -->
56- <div id="login-section">
57- <div class="flex flex-col items-center">
58- <h2 class="card-title text-center mb-4">Login</h2>
59-
60- <!-- Error Message Display -->
61- {{ if .Error }}
62- <div class="alert alert-error mb-4">
63- <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
64- <span>
65- {{ if eq .Error "empty_token" }}
66- Please enter a token.
67- {{ else if eq .Error "invalid_token" }}
68- Invalid token. Please try again.
69- {{ else if eq .Error "server_error" }}
70- A server error occurred. Please try again later.
71- {{ else }}
72- An error occurred. Please try again.
73- {{ end }}
74- </span>
75- </div>
76- {{ end }}
77-
78- <!-- Token Input Form -->
79- <div class="w-full">
80- <form action="login/submit-token" method="POST" class="mb-4">
81- <div class="form-control mb-4">
82- <label for="token" class="label">
83- <span class="label-text">Admin Token</span>
84- </label>
85- <input
86- type="text"
87- id="token"
88- name="token"
89- placeholder="Enter your admin token"
90- class="input input-bordered w-full"
91- required
92- />
93- </div>
94- <button
95- type="submit"
96- class="btn btn-primary w-full"
97- >
98- Continue with Token
99- </button>
100- </form>
101- <div class="divider">OR</div>
102- </div>
103-
104- <!-- GitHub OAuth -->
105- <div class="w-full">
106- <a
107- href="login/oauth/github"
108- class="btn btn-secondary w-full"
109- >
110- <svg class="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
111- <path fill-rule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clip-rule="evenodd" />
112- </svg>
113- Sign in with GitHub
114- </a>
115- </div>
116- </div>
117- </div>
118- <!-- End Login Section -->
119- </div>
120- </div>
121- </body>
122-</html>
@@ -1,122 +0,0 @@
1-<!doctype html>
2-<html lang="en" data-theme="corporate">
3- <head>
4- <meta charset="UTF-8" />
5- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6- <title>{{ .header.meta.title }}</title>
7- <meta name="description" content="{{ .Header.Meta.Description }}" />
8- <meta name="keywords" content="{{ .Header.Meta.Keywords }}" />
9- <!-- Custom CSS + Tailwind -->
10- <link rel="stylesheet" href="/assets/css/tailwind.css" />
11- <!-- HTMX min js -->
12- <script src="/libs/htmx.min.js"></script>
13- <!-- HTMX SSE -->
14- <script src="/libs/htmx-sse.js"></script>
15- <!-- DaisyUI -->
16- <script src="/libs/daisyui.js"></script>
17- <!-- Custom JS from assets/js -->
18- <script src="/assets/js/custom.js"></script>
19-
20- <!-- daisyUI theme configuration -->
21- <script>
22- // Check for saved theme preference or use preferred color scheme
23- const savedTheme = localStorage.getItem('theme') || 'corporate';
24- document.documentElement.setAttribute('data-theme', savedTheme);
25- console.debug('Theme loaded:', savedTheme);
26- </script>
27- </head>
28-
29- <body class="IBMPlexSans bg-base-200 min-h-screen flex flex-col items-center justify-center p-4">
30- <!-- Logo and Title -->
31- <div
32- id="logo-mascot"
33- class="flex flex-col justify-center items-center p-4 mb-4">
34- <div class="flex justify-center w-100">
35- <img
36- src="/assets/imgs/gordon-mascot-mq-trsp.png"
37- alt="logo gordon"
38- class="sm:h-24 h-32 xl:h-48 drop-shadow-xl" />
39- </div>
40- <div class="flex justify-center mt-4">
41- <h1
42- class="gordon-title text-4xl lg:text-4xl xl:text-5xl text-primary tracking-tighter">
43- Gordon
44- </h1>
45- <span
46- class="gordon-v-subtitle text-secondary text-sm ml-2"
47- >{{ .BuildVersion }}</span
48- >
49- </div>
50- </div>
51-
52- <!-- Main Content -->
53- <div class="card bg-base-100 shadow-xl w-full max-w-md">
54- <div class="card-body">
55- <!-- Login Section -->
56- <div id="login-section">
57- <div class="flex flex-col items-center">
58- <h2 class="card-title text-center mb-4">Login</h2>
59-
60- <!-- Error Message Display -->
61- {{ if .Error }}
62- <div class="alert alert-error mb-4">
63- <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
64- <span>
65- {{ if eq .Error "empty_token" }}
66- Please enter a token.
67- {{ else if eq .Error "invalid_token" }}
68- Invalid token. Please try again.
69- {{ else if eq .Error "server_error" }}
70- A server error occurred. Please try again later.
71- {{ else }}
72- An error occurred. Please try again.
73- {{ end }}
74- </span>
75- </div>
76- {{ end }}
77-
78- <!-- Token Input Form -->
79- <div class="w-full">
80- <form action="login/submit-token" method="POST" class="mb-4">
81- <div class="form-control mb-4">
82- <label for="token" class="label">
83- <span class="label-text">Admin Token</span>
84- </label>
85- <input
86- type="text"
87- id="token"
88- name="token"
89- placeholder="Enter your admin token"
90- class="input input-bordered w-full"
91- required
92- />
93- </div>
94- <button
95- type="submit"
96- class="btn btn-primary w-full"
97- >
98- Continue with Token
99- </button>
100- </form>
101- <div class="divider">OR</div>
102- </div>
103-
104- <!-- GitHub OAuth -->
105- <div class="w-full">
106- <a
107- href="login/oauth/github"
108- class="btn btn-secondary w-full"
109- >
110- <svg class="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
111- <path fill-rule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clip-rule="evenodd" />
112- </svg>
113- Sign in with GitHub
114- </a>
115- </div>
116- </div>
117- </div>
118- <!-- End Login Section -->
119- </div>
120- </div>
121- </body>
122-</html>
M internal/templating/models/templ/components/header.templ +1 -0
@@ -14,6 +14,7 @@ templ Header(title string) {
1414 <script src="/libs/htmx-response-targets.js"></script>
1515 <script src="/libs/htmx-debug.js"></script>
1616 <script src="/libs/htmx-sse.js"></script>
17+ <script src="/libs/daisyui.js" type="module"></script>
1718 <script defer src="/libs/hyperscript.min.js"></script>
1819 <script defer src="/assets/js/custom.js"></script>
1920
@@ -14,6 +14,7 @@ templ Header(title string) {
14 <script src="/libs/htmx-response-targets.js"></script>14 <script src="/libs/htmx-response-targets.js"></script>
15 <script src="/libs/htmx-debug.js"></script>15 <script src="/libs/htmx-debug.js"></script>
16 <script src="/libs/htmx-sse.js"></script>16 <script src="/libs/htmx-sse.js"></script>
17+ <script src="/libs/daisyui.js" type="module"></script>
17 <script defer src="/libs/hyperscript.min.js"></script>18 <script defer src="/libs/hyperscript.min.js"></script>
18 <script defer src="/assets/js/custom.js"></script>19 <script defer src="/assets/js/custom.js"></script>
19 20
M internal/templating/models/templ/components/header_templ.go +1 -1 generated file: collapsed
M internal/templating/models/templ/pages/login/index.templ +23 -11
@@ -17,9 +17,11 @@ templ LoginPage(title string, buildVersion string, errorMsg string) {
1717 <!-- HTMX SSE -->
1818 <script src="/libs/htmx-sse.js"></script>
1919 <!-- DaisyUI -->
20- <script src="/libs/daisyui.js"></script>
20+ <script src="/libs/daisyui.js" type="module"></script>
21+ <!-- jQuery -->
22+ <script src="/libs/jquery.min.js"></script>
2123 <!-- Custom JS from assets/js -->
22- <script src="/assets/js/custom.js"></script>
24+ <script src="/assets/js/custom.js" defer></script>
2325
2426 <!-- daisyUI theme configuration -->
2527 <script>
@@ -65,16 +67,26 @@ templ LoginPage(title string, buildVersion string, errorMsg string) {
6567 <div class="alert alert-error mb-4">
6668 <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
6769 <span>
68- // Removed token-specific errors. Display generic or GitHub-related errors.
69- if errorMsg == "server_error" {
70- A server error occurred during login. Please try again later.
71- } else if errorMsg == "github_error" {
72- An error occurred during GitHub authentication. Please try again.
73- } else if errorMsg == "unauthorized" {
74- This GitHub account is not authorized.
75- } else {
76- An unknown login error occurred. Please try again.
70+ // --- MODIFIED: Handle new error messages ---
71+ switch errorMsg {
72+ case "server_error":
73+ A server error occurred during login. Please try again later or contact support.
74+ case "github_error": // Kept for potential future use
75+ An error occurred during GitHub authentication. Please try again.
76+ case "unauthorized":
77+ This GitHub account is not authorized to access Gordon.
78+ case "email_mismatch":
79+ Login failed: Your GitHub email does not match the configured administrator email.
80+ case "no_email":
81+ Login failed: Your GitHub account must have a public primary email address configured.
82+ case "config_email_missing":
83+ Server Configuration Error: The administrator email is not set up. Please contact support.
84+ case "session_invalid_or_expired": // Added from login page handler
85+ Your session is invalid or has expired. Please log in again.
86+ default:
87+ An unknown login error occurred: { errorMsg }
7788 }
89+ // --- END MODIFICATION ---
7890 </span>
7991 </div>
8092 }
@@ -17,9 +17,11 @@ templ LoginPage(title string, buildVersion string, errorMsg string) {
17 <!-- HTMX SSE -->17 <!-- HTMX SSE -->
18 <script src="/libs/htmx-sse.js"></script>18 <script src="/libs/htmx-sse.js"></script>
19 <!-- DaisyUI -->19 <!-- DaisyUI -->
20- <script src="/libs/daisyui.js"></script>20+ <script src="/libs/daisyui.js" type="module"></script>
21+ <!-- jQuery -->
22+ <script src="/libs/jquery.min.js"></script>
21 <!-- Custom JS from assets/js -->23 <!-- Custom JS from assets/js -->
22- <script src="/assets/js/custom.js"></script>24+ <script src="/assets/js/custom.js" defer></script>
23 25
24 <!-- daisyUI theme configuration -->26 <!-- daisyUI theme configuration -->
25 <script>27 <script>
@@ -65,16 +67,26 @@ templ LoginPage(title string, buildVersion string, errorMsg string) {
65 <div class="alert alert-error mb-4">67 <div class="alert alert-error mb-4">
66 <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>68 <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
67 <span>69 <span>
68- // Removed token-specific errors. Display generic or GitHub-related errors.70+ // --- MODIFIED: Handle new error messages ---
69- if errorMsg == "server_error" {71+ switch errorMsg {
70- A server error occurred during login. Please try again later.72+ case "server_error":
71- } else if errorMsg == "github_error" {73+ A server error occurred during login. Please try again later or contact support.
72- An error occurred during GitHub authentication. Please try again.74+ case "github_error": // Kept for potential future use
73- } else if errorMsg == "unauthorized" {75+ An error occurred during GitHub authentication. Please try again.
74- This GitHub account is not authorized.76+ case "unauthorized":
75- } else {77+ This GitHub account is not authorized to access Gordon.
76- An unknown login error occurred. Please try again.78+ case "email_mismatch":
79+ Login failed: Your GitHub email does not match the configured administrator email.
80+ case "no_email":
81+ Login failed: Your GitHub account must have a public primary email address configured.
82+ case "config_email_missing":
83+ Server Configuration Error: The administrator email is not set up. Please contact support.
84+ case "session_invalid_or_expired": // Added from login page handler
85+ Your session is invalid or has expired. Please log in again.
86+ default:
87+ An unknown login error occurred: { errorMsg }
77 }88 }
89+ // --- END MODIFICATION ---
78 </span>90 </span>
79 </div>91 </div>
80 }92 }
M internal/templating/models/templ/pages/login/index_templ.go +42 -12 generated file: collapsed
D internal/templating/render/html.go +0 -77
@@ -1,77 +0,0 @@
1-package render
2-
3-import (
4- "bytes"
5- "fmt"
6- "html/template"
7- "io/fs"
8- "path"
9-
10- "github.com/bnema/gordon/internal/server"
11-)
12-
13-type Renderer struct {
14- Template *template.Template
15- ParseError error
16- BuildVersion string
17- LatestVersion string
18-}
19-
20-// Render function renders the template with the given data
21-func (r *Renderer) Render(data interface{}, a *server.App) (string, error) {
22- if r.ParseError != nil {
23- return "", fmt.Errorf("failed to parse template: %w", r.ParseError)
24-
25- }
26- if r.Template == nil {
27- return "", fmt.Errorf("template is nil")
28- }
29- // Type assert data to map[string]interface{} to add BuildVersion
30- dataMap, ok := data.(map[string]interface{})
31- if !ok {
32- return "", fmt.Errorf("data is not a map[string]interface{}")
33- }
34-
35- // Automatically add BuildVersion to the data map
36- dataMap["BuildVersion"] = r.BuildVersion
37- buf := new(bytes.Buffer)
38- err := r.Template.Execute(buf, dataMap)
39- if err != nil {
40- return "", fmt.Errorf("failed to execute template: %w", err)
41- }
42-
43- return buf.String(), nil
44-}
45-
46-// GetHTMLRenderer function returns a new Renderer instance
47-func GetHTMLRenderer(mainPath string, filename string, fs fs.FS, a *server.App, fragmentsPath ...string) (*Renderer, error) {
48- // Full path to the main template
49- fullPath := path.Join(mainPath, filename)
50- // Check if the file exists in the provided fs.FS using fs.Open
51- file, err := fs.Open(fullPath)
52- if err != nil {
53- return nil, fmt.Errorf("failed to open %s: %w", filename, err)
54- }
55- file.Close()
56-
57- // Parse the main template
58- tmpl, err := template.New(filename).ParseFS(fs, fullPath)
59- if err != nil {
60- return nil, fmt.Errorf("failed to parse %s: %w", filename, err)
61- }
62-
63- // If a fragments path is provided, parse the fragment templates
64- if len(fragmentsPath) > 0 && fragmentsPath[0] != "" {
65- fragmentsGlob := path.Join(fragmentsPath[0], "*.gohtml")
66- _, err = tmpl.ParseFS(fs, fragmentsGlob)
67- if err != nil {
68- return nil, fmt.Errorf("failed to parse fragments: %w", err)
69- }
70- }
71-
72- // Return the Renderer instance
73- return &Renderer{
74- Template: tmpl,
75- BuildVersion: a.Config.Build.BuildVersion,
76- }, nil
77-}
@@ -1,77 +0,0 @@
1-package render
2-
3-import (
4- "bytes"
5- "fmt"
6- "html/template"
7- "io/fs"
8- "path"
9-
10- "github.com/bnema/gordon/internal/server"
11-)
12-
13-type Renderer struct {
14- Template *template.Template
15- ParseError error
16- BuildVersion string
17- LatestVersion string
18-}
19-
20-// Render function renders the template with the given data
21-func (r *Renderer) Render(data interface{}, a *server.App) (string, error) {
22- if r.ParseError != nil {
23- return "", fmt.Errorf("failed to parse template: %w", r.ParseError)
24-
25- }
26- if r.Template == nil {
27- return "", fmt.Errorf("template is nil")
28- }
29- // Type assert data to map[string]interface{} to add BuildVersion
30- dataMap, ok := data.(map[string]interface{})
31- if !ok {
32- return "", fmt.Errorf("data is not a map[string]interface{}")
33- }
34-
35- // Automatically add BuildVersion to the data map
36- dataMap["BuildVersion"] = r.BuildVersion
37- buf := new(bytes.Buffer)
38- err := r.Template.Execute(buf, dataMap)
39- if err != nil {
40- return "", fmt.Errorf("failed to execute template: %w", err)
41- }
42-
43- return buf.String(), nil
44-}
45-
46-// GetHTMLRenderer function returns a new Renderer instance
47-func GetHTMLRenderer(mainPath string, filename string, fs fs.FS, a *server.App, fragmentsPath ...string) (*Renderer, error) {
48- // Full path to the main template
49- fullPath := path.Join(mainPath, filename)
50- // Check if the file exists in the provided fs.FS using fs.Open
51- file, err := fs.Open(fullPath)
52- if err != nil {
53- return nil, fmt.Errorf("failed to open %s: %w", filename, err)
54- }
55- file.Close()
56-
57- // Parse the main template
58- tmpl, err := template.New(filename).ParseFS(fs, fullPath)
59- if err != nil {
60- return nil, fmt.Errorf("failed to parse %s: %w", filename, err)
61- }
62-
63- // If a fragments path is provided, parse the fragment templates
64- if len(fragmentsPath) > 0 && fragmentsPath[0] != "" {
65- fragmentsGlob := path.Join(fragmentsPath[0], "*.gohtml")
66- _, err = tmpl.ParseFS(fs, fragmentsGlob)
67- if err != nil {
68- return nil, fmt.Errorf("failed to parse fragments: %w", err)
69- }
70- }
71-
72- // Return the Renderer instance
73- return &Renderer{
74- Template: tmpl,
75- BuildVersion: a.Config.Build.BuildVersion,
76- }, nil
77-}
M internal/webui/public/assets/css/custom.css +10 -10
@@ -22,8 +22,8 @@
2222 --color-base-content: oklch(93% 0.034 272.788);
2323 --color-primary: oklch(87% 0 0);
2424 --color-primary-content: oklch(14% 0 0);
25- --color-secondary: oklch(83% 0.145 321.434);
26- --color-secondary-content: oklch(29% 0.136 325.661);
25+ --color-secondary: oklch(80% 0.105 251.813);
26+ --color-secondary-content: oklch(28% 0.091 267.935);
2727 --color-accent: oklch(80% 0.105 251.813);
2828 --color-accent-content: oklch(28% 0.091 267.935);
2929 --color-neutral: oklch(37% 0.034 259.733);
@@ -36,9 +36,9 @@
3636 --color-warning-content: oklch(98% 0.022 95.277);
3737 --color-error: oklch(64% 0.246 16.439);
3838 --color-error-content: oklch(96% 0.015 12.422);
39- --radius-selector: 1rem;
39+ --radius-selector: 0rem;
4040 --radius-field: 0rem;
41- --radius-box: 0.25rem;
41+ --radius-box: 0rem;
4242 --size-selector: 0.25rem;
4343 --size-field: 0.25rem;
4444 --border: 1px;
@@ -51,20 +51,20 @@
5151 @layer components {
5252 /* Button styles */
5353 .btn-primary {
54- @apply flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2;
54+ @apply flex w-full justify-center border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2;
5555 }
5656
5757 .btn-secondary {
58- @apply flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2;
58+ @apply flex w-full justify-center border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2;
5959 }
6060
6161 .btn-danger {
62- @apply flex w-full justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2;
62+ @apply flex w-full justify-center border border-transparent bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2;
6363 }
6464
6565 /* Alert styles */
6666 .alert-error {
67- @apply rounded-md bg-red-50 p-4 dark:bg-red-900/20;
67+ @apply bg-red-50 p-4 dark:bg-red-900/20;
6868 }
6969
7070 .alert-error-icon {
@@ -76,7 +76,7 @@
7676 }
7777
7878 .alert-success {
79- @apply rounded-md bg-green-50 p-4 dark:bg-green-900/20;
79+ @apply bg-green-50 p-4 dark:bg-green-900/20;
8080 }
8181
8282 .alert-success-icon {
@@ -89,7 +89,7 @@
8989
9090 /* Card styles */
9191 .card {
92- @apply bg-white p-4 rounded-md shadow mb-4;
92+ @apply bg-white p-4 shadow mb-4;
9393 }
9494
9595 .card-header {
@@ -22,8 +22,8 @@
22 --color-base-content: oklch(93% 0.034 272.788);22 --color-base-content: oklch(93% 0.034 272.788);
23 --color-primary: oklch(87% 0 0);23 --color-primary: oklch(87% 0 0);
24 --color-primary-content: oklch(14% 0 0);24 --color-primary-content: oklch(14% 0 0);
25- --color-secondary: oklch(83% 0.145 321.434);25+ --color-secondary: oklch(80% 0.105 251.813);
26- --color-secondary-content: oklch(29% 0.136 325.661);26+ --color-secondary-content: oklch(28% 0.091 267.935);
27 --color-accent: oklch(80% 0.105 251.813);27 --color-accent: oklch(80% 0.105 251.813);
28 --color-accent-content: oklch(28% 0.091 267.935);28 --color-accent-content: oklch(28% 0.091 267.935);
29 --color-neutral: oklch(37% 0.034 259.733);29 --color-neutral: oklch(37% 0.034 259.733);
@@ -36,9 +36,9 @@
36 --color-warning-content: oklch(98% 0.022 95.277);36 --color-warning-content: oklch(98% 0.022 95.277);
37 --color-error: oklch(64% 0.246 16.439);37 --color-error: oklch(64% 0.246 16.439);
38 --color-error-content: oklch(96% 0.015 12.422);38 --color-error-content: oklch(96% 0.015 12.422);
39- --radius-selector: 1rem;39+ --radius-selector: 0rem;
40 --radius-field: 0rem;40 --radius-field: 0rem;
41- --radius-box: 0.25rem;41+ --radius-box: 0rem;
42 --size-selector: 0.25rem;42 --size-selector: 0.25rem;
43 --size-field: 0.25rem;43 --size-field: 0.25rem;
44 --border: 1px;44 --border: 1px;
@@ -51,20 +51,20 @@
51 @layer components {51 @layer components {
52 /* Button styles */52 /* Button styles */
53 .btn-primary {53 .btn-primary {
54- @apply flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2;54+ @apply flex w-full justify-center border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2;
55 }55 }
56 56
57 .btn-secondary {57 .btn-secondary {
58- @apply flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2;58+ @apply flex w-full justify-center border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2;
59 }59 }
60 60
61 .btn-danger {61 .btn-danger {
62- @apply flex w-full justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2;62+ @apply flex w-full justify-center border border-transparent bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2;
63 }63 }
64 64
65 /* Alert styles */65 /* Alert styles */
66 .alert-error {66 .alert-error {
67- @apply rounded-md bg-red-50 p-4 dark:bg-red-900/20;67+ @apply bg-red-50 p-4 dark:bg-red-900/20;
68 }68 }
69 69
70 .alert-error-icon {70 .alert-error-icon {
@@ -76,7 +76,7 @@
76 }76 }
77 77
78 .alert-success {78 .alert-success {
79- @apply rounded-md bg-green-50 p-4 dark:bg-green-900/20;79+ @apply bg-green-50 p-4 dark:bg-green-900/20;
80 }80 }
81 81
82 .alert-success-icon {82 .alert-success-icon {
@@ -89,7 +89,7 @@
89 89
90 /* Card styles */90 /* Card styles */
91 .card {91 .card {
92- @apply bg-white p-4 rounded-md shadow mb-4;92+ @apply bg-white p-4 shadow mb-4;
93 }93 }
94 94
95 .card-header {95 .card-header {
M internal/webui/public/assets/css/tailwind.css +8 -61
@@ -4529,9 +4529,6 @@
45294529 .mt-8 {
45304530 margin-top: calc(var(--spacing) * 8);
45314531 }
4532- .mt-16 {
4533- margin-top: calc(var(--spacing) * 16);
4534- }
45354532 .breadcrumbs {
45364533 max-width: 100%;
45374534 overflow-x: auto;
@@ -5393,9 +5390,6 @@
53935390 .max-w-md {
53945391 max-width: var(--container-md);
53955392 }
5396- .flex-1 {
5397- flex: 1;
5398- }
53995393 .flex-shrink {
54005394 flex-shrink: 1;
54015395 }
@@ -6311,9 +6305,6 @@
63116305 font-size: 1.375rem;
63126306 }
63136307 }
6314- .pt-16 {
6315- padding-top: calc(var(--spacing) * 16);
6316- }
63176308 .pt-20 {
63186309 padding-top: calc(var(--spacing) * 20);
63196310 }
@@ -6725,9 +6716,6 @@
67256716 .progress-warning {
67266717 color: var(--color-warning);
67276718 }
6728- .text-accent {
6729- color: var(--color-accent);
6730- }
67316719 .text-base-content {
67326720 color: var(--color-base-content);
67336721 }
@@ -7548,11 +7536,6 @@
75487536 grid-column: span 5 / span 5;
75497537 }
75507538 }
7551- .md\:ml-4 {
7552- @media (width >= 48rem) {
7553- margin-left: calc(var(--spacing) * 4);
7554- }
7555- }
75567539 .md\:w-2\/6 {
75577540 @media (width >= 48rem) {
75587541 width: calc(2/6 * 100%);
@@ -7606,31 +7589,6 @@
76067589 justify-self: flex-end;
76077590 }
76087591 }
7609- .md\:p-0 {
7610- @media (width >= 48rem) {
7611- padding: calc(var(--spacing) * 0);
7612- }
7613- }
7614- .md\:p-8 {
7615- @media (width >= 48rem) {
7616- padding: calc(var(--spacing) * 8);
7617- }
7618- }
7619- .md\:px-6 {
7620- @media (width >= 48rem) {
7621- padding-inline: calc(var(--spacing) * 6);
7622- }
7623- }
7624- .md\:pt-10 {
7625- @media (width >= 48rem) {
7626- padding-top: calc(var(--spacing) * 10);
7627- }
7628- }
7629- .md\:pt-32 {
7630- @media (width >= 48rem) {
7631- padding-top: calc(var(--spacing) * 32);
7632- }
7633- }
76347592 .md\:text-3xl {
76357593 @media (width >= 48rem) {
76367594 font-size: var(--text-3xl);
@@ -7654,11 +7612,6 @@
76547612 display: flex;
76557613 }
76567614 }
7657- .lg\:flex-row {
7658- @media (width >= 64rem) {
7659- flex-direction: row;
7660- }
7661- }
76627615 .lg\:text-4xl {
76637616 @media (width >= 64rem) {
76647617 font-size: var(--text-4xl);
@@ -7682,7 +7635,6 @@
76827635 display: flex;
76837636 width: 100%;
76847637 justify-content: center;
7685- border-radius: var(--radius-md);
76867638 border-style: var(--tw-border-style);
76877639 border-width: 1px;
76887640 border-color: transparent;
@@ -7719,7 +7671,6 @@
77197671 display: flex;
77207672 width: 100%;
77217673 justify-content: center;
7722- border-radius: var(--radius-md);
77237674 border-style: var(--tw-border-style);
77247675 border-width: 1px;
77257676 border-color: var(--color-gray-300);
@@ -7756,7 +7707,6 @@
77567707 display: flex;
77577708 width: 100%;
77587709 justify-content: center;
7759- border-radius: var(--radius-md);
77607710 border-style: var(--tw-border-style);
77617711 border-width: 1px;
77627712 border-color: transparent;
@@ -7790,7 +7740,6 @@
77907740 }
77917741 }
77927742 .alert-error {
7793- border-radius: var(--radius-md);
77947743 background-color: var(--color-red-50);
77957744 padding: calc(var(--spacing) * 4);
77967745 @media (prefers-color-scheme: dark) {
@@ -7813,7 +7762,6 @@
78137762 }
78147763 }
78157764 .alert-success {
7816- border-radius: var(--radius-md);
78177765 background-color: var(--color-green-50);
78187766 padding: calc(var(--spacing) * 4);
78197767 @media (prefers-color-scheme: dark) {
@@ -7837,7 +7785,6 @@
78377785 }
78387786 .card {
78397787 margin-bottom: calc(var(--spacing) * 4);
7840- border-radius: var(--radius-md);
78417788 background-color: var(--color-white);
78427789 padding: calc(var(--spacing) * 4);
78437790 --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
@@ -8188,8 +8135,8 @@
81888135 --color-base-content: oklch(93% 0.034 272.788);
81898136 --color-primary: oklch(87% 0 0);
81908137 --color-primary-content: oklch(14% 0 0);
8191- --color-secondary: oklch(83% 0.145 321.434);
8192- --color-secondary-content: oklch(29% 0.136 325.661);
8138+ --color-secondary: oklch(80% 0.105 251.813);
8139+ --color-secondary-content: oklch(28% 0.091 267.935);
81938140 --color-accent: oklch(80% 0.105 251.813);
81948141 --color-accent-content: oklch(28% 0.091 267.935);
81958142 --color-neutral: oklch(37% 0.034 259.733);
@@ -8202,9 +8149,9 @@
82028149 --color-warning-content: oklch(98% 0.022 95.277);
82038150 --color-error: oklch(64% 0.246 16.439);
82048151 --color-error-content: oklch(96% 0.015 12.422);
8205- --radius-selector: 1rem;
8152+ --radius-selector: 0rem;
82068153 --radius-field: 0rem;
8207- --radius-box: 0.25rem;
8154+ --radius-box: 0rem;
82088155 --size-selector: 0.25rem;
82098156 --size-field: 0.25rem;
82108157 --border: 1px;
@@ -8222,8 +8169,8 @@
82228169 --color-base-content: oklch(93% 0.034 272.788);
82238170 --color-primary: oklch(87% 0 0);
82248171 --color-primary-content: oklch(14% 0 0);
8225- --color-secondary: oklch(83% 0.145 321.434);
8226- --color-secondary-content: oklch(29% 0.136 325.661);
8172+ --color-secondary: oklch(80% 0.105 251.813);
8173+ --color-secondary-content: oklch(28% 0.091 267.935);
82278174 --color-accent: oklch(80% 0.105 251.813);
82288175 --color-accent-content: oklch(28% 0.091 267.935);
82298176 --color-neutral: oklch(37% 0.034 259.733);
@@ -8236,9 +8183,9 @@
82368183 --color-warning-content: oklch(98% 0.022 95.277);
82378184 --color-error: oklch(64% 0.246 16.439);
82388185 --color-error-content: oklch(96% 0.015 12.422);
8239- --radius-selector: 1rem;
8186+ --radius-selector: 0rem;
82408187 --radius-field: 0rem;
8241- --radius-box: 0.25rem;
8188+ --radius-box: 0rem;
82428189 --size-selector: 0.25rem;
82438190 --size-field: 0.25rem;
82448191 --border: 1px;
@@ -4529,9 +4529,6 @@
4529 .mt-8 {4529 .mt-8 {
4530 margin-top: calc(var(--spacing) * 8);4530 margin-top: calc(var(--spacing) * 8);
4531 }4531 }
4532- .mt-16 {
4533- margin-top: calc(var(--spacing) * 16);
4534- }
4535 .breadcrumbs {4532 .breadcrumbs {
4536 max-width: 100%;4533 max-width: 100%;
4537 overflow-x: auto;4534 overflow-x: auto;
@@ -5393,9 +5390,6 @@
5393 .max-w-md {5390 .max-w-md {
5394 max-width: var(--container-md);5391 max-width: var(--container-md);
5395 }5392 }
5396- .flex-1 {
5397- flex: 1;
5398- }
5399 .flex-shrink {5393 .flex-shrink {
5400 flex-shrink: 1;5394 flex-shrink: 1;
5401 }5395 }
@@ -6311,9 +6305,6 @@
6311 font-size: 1.375rem;6305 font-size: 1.375rem;
6312 }6306 }
6313 }6307 }
6314- .pt-16 {
6315- padding-top: calc(var(--spacing) * 16);
6316- }
6317 .pt-20 {6308 .pt-20 {
6318 padding-top: calc(var(--spacing) * 20);6309 padding-top: calc(var(--spacing) * 20);
6319 }6310 }
@@ -6725,9 +6716,6 @@
6725 .progress-warning {6716 .progress-warning {
6726 color: var(--color-warning);6717 color: var(--color-warning);
6727 }6718 }
6728- .text-accent {
6729- color: var(--color-accent);
6730- }
6731 .text-base-content {6719 .text-base-content {
6732 color: var(--color-base-content);6720 color: var(--color-base-content);
6733 }6721 }
@@ -7548,11 +7536,6 @@
7548 grid-column: span 5 / span 5;7536 grid-column: span 5 / span 5;
7549 }7537 }
7550 }7538 }
7551- .md\:ml-4 {
7552- @media (width >= 48rem) {
7553- margin-left: calc(var(--spacing) * 4);
7554- }
7555- }
7556 .md\:w-2\/6 {7539 .md\:w-2\/6 {
7557 @media (width >= 48rem) {7540 @media (width >= 48rem) {
7558 width: calc(2/6 * 100%);7541 width: calc(2/6 * 100%);
@@ -7606,31 +7589,6 @@
7606 justify-self: flex-end;7589 justify-self: flex-end;
7607 }7590 }
7608 }7591 }
7609- .md\:p-0 {
7610- @media (width >= 48rem) {
7611- padding: calc(var(--spacing) * 0);
7612- }
7613- }
7614- .md\:p-8 {
7615- @media (width >= 48rem) {
7616- padding: calc(var(--spacing) * 8);
7617- }
7618- }
7619- .md\:px-6 {
7620- @media (width >= 48rem) {
7621- padding-inline: calc(var(--spacing) * 6);
7622- }
7623- }
7624- .md\:pt-10 {
7625- @media (width >= 48rem) {
7626- padding-top: calc(var(--spacing) * 10);
7627- }
7628- }
7629- .md\:pt-32 {
7630- @media (width >= 48rem) {
7631- padding-top: calc(var(--spacing) * 32);
7632- }
7633- }
7634 .md\:text-3xl {7592 .md\:text-3xl {
7635 @media (width >= 48rem) {7593 @media (width >= 48rem) {
7636 font-size: var(--text-3xl);7594 font-size: var(--text-3xl);
@@ -7654,11 +7612,6 @@
7654 display: flex;7612 display: flex;
7655 }7613 }
7656 }7614 }
7657- .lg\:flex-row {
7658- @media (width >= 64rem) {
7659- flex-direction: row;
7660- }
7661- }
7662 .lg\:text-4xl {7615 .lg\:text-4xl {
7663 @media (width >= 64rem) {7616 @media (width >= 64rem) {
7664 font-size: var(--text-4xl);7617 font-size: var(--text-4xl);
@@ -7682,7 +7635,6 @@
7682 display: flex;7635 display: flex;
7683 width: 100%;7636 width: 100%;
7684 justify-content: center;7637 justify-content: center;
7685- border-radius: var(--radius-md);
7686 border-style: var(--tw-border-style);7638 border-style: var(--tw-border-style);
7687 border-width: 1px;7639 border-width: 1px;
7688 border-color: transparent;7640 border-color: transparent;
@@ -7719,7 +7671,6 @@
7719 display: flex;7671 display: flex;
7720 width: 100%;7672 width: 100%;
7721 justify-content: center;7673 justify-content: center;
7722- border-radius: var(--radius-md);
7723 border-style: var(--tw-border-style);7674 border-style: var(--tw-border-style);
7724 border-width: 1px;7675 border-width: 1px;
7725 border-color: var(--color-gray-300);7676 border-color: var(--color-gray-300);
@@ -7756,7 +7707,6 @@
7756 display: flex;7707 display: flex;
7757 width: 100%;7708 width: 100%;
7758 justify-content: center;7709 justify-content: center;
7759- border-radius: var(--radius-md);
7760 border-style: var(--tw-border-style);7710 border-style: var(--tw-border-style);
7761 border-width: 1px;7711 border-width: 1px;
7762 border-color: transparent;7712 border-color: transparent;
@@ -7790,7 +7740,6 @@
7790 }7740 }
7791 }7741 }
7792 .alert-error {7742 .alert-error {
7793- border-radius: var(--radius-md);
7794 background-color: var(--color-red-50);7743 background-color: var(--color-red-50);
7795 padding: calc(var(--spacing) * 4);7744 padding: calc(var(--spacing) * 4);
7796 @media (prefers-color-scheme: dark) {7745 @media (prefers-color-scheme: dark) {
@@ -7813,7 +7762,6 @@
7813 }7762 }
7814 }7763 }
7815 .alert-success {7764 .alert-success {
7816- border-radius: var(--radius-md);
7817 background-color: var(--color-green-50);7765 background-color: var(--color-green-50);
7818 padding: calc(var(--spacing) * 4);7766 padding: calc(var(--spacing) * 4);
7819 @media (prefers-color-scheme: dark) {7767 @media (prefers-color-scheme: dark) {
@@ -7837,7 +7785,6 @@
7837 }7785 }
7838 .card {7786 .card {
7839 margin-bottom: calc(var(--spacing) * 4);7787 margin-bottom: calc(var(--spacing) * 4);
7840- border-radius: var(--radius-md);
7841 background-color: var(--color-white);7788 background-color: var(--color-white);
7842 padding: calc(var(--spacing) * 4);7789 padding: calc(var(--spacing) * 4);
7843 --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));7790 --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
@@ -8188,8 +8135,8 @@
8188 --color-base-content: oklch(93% 0.034 272.788);8135 --color-base-content: oklch(93% 0.034 272.788);
8189 --color-primary: oklch(87% 0 0);8136 --color-primary: oklch(87% 0 0);
8190 --color-primary-content: oklch(14% 0 0);8137 --color-primary-content: oklch(14% 0 0);
8191- --color-secondary: oklch(83% 0.145 321.434);8138+ --color-secondary: oklch(80% 0.105 251.813);
8192- --color-secondary-content: oklch(29% 0.136 325.661);8139+ --color-secondary-content: oklch(28% 0.091 267.935);
8193 --color-accent: oklch(80% 0.105 251.813);8140 --color-accent: oklch(80% 0.105 251.813);
8194 --color-accent-content: oklch(28% 0.091 267.935);8141 --color-accent-content: oklch(28% 0.091 267.935);
8195 --color-neutral: oklch(37% 0.034 259.733);8142 --color-neutral: oklch(37% 0.034 259.733);
@@ -8202,9 +8149,9 @@
8202 --color-warning-content: oklch(98% 0.022 95.277);8149 --color-warning-content: oklch(98% 0.022 95.277);
8203 --color-error: oklch(64% 0.246 16.439);8150 --color-error: oklch(64% 0.246 16.439);
8204 --color-error-content: oklch(96% 0.015 12.422);8151 --color-error-content: oklch(96% 0.015 12.422);
8205- --radius-selector: 1rem;8152+ --radius-selector: 0rem;
8206 --radius-field: 0rem;8153 --radius-field: 0rem;
8207- --radius-box: 0.25rem;8154+ --radius-box: 0rem;
8208 --size-selector: 0.25rem;8155 --size-selector: 0.25rem;
8209 --size-field: 0.25rem;8156 --size-field: 0.25rem;
8210 --border: 1px;8157 --border: 1px;
@@ -8222,8 +8169,8 @@
8222 --color-base-content: oklch(93% 0.034 272.788);8169 --color-base-content: oklch(93% 0.034 272.788);
8223 --color-primary: oklch(87% 0 0);8170 --color-primary: oklch(87% 0 0);
8224 --color-primary-content: oklch(14% 0 0);8171 --color-primary-content: oklch(14% 0 0);
8225- --color-secondary: oklch(83% 0.145 321.434);8172+ --color-secondary: oklch(80% 0.105 251.813);
8226- --color-secondary-content: oklch(29% 0.136 325.661);8173+ --color-secondary-content: oklch(28% 0.091 267.935);
8227 --color-accent: oklch(80% 0.105 251.813);8174 --color-accent: oklch(80% 0.105 251.813);
8228 --color-accent-content: oklch(28% 0.091 267.935);8175 --color-accent-content: oklch(28% 0.091 267.935);
8229 --color-neutral: oklch(37% 0.034 259.733);8176 --color-neutral: oklch(37% 0.034 259.733);
@@ -8236,9 +8183,9 @@
8236 --color-warning-content: oklch(98% 0.022 95.277);8183 --color-warning-content: oklch(98% 0.022 95.277);
8237 --color-error: oklch(64% 0.246 16.439);8184 --color-error: oklch(64% 0.246 16.439);
8238 --color-error-content: oklch(96% 0.015 12.422);8185 --color-error-content: oklch(96% 0.015 12.422);
8239- --radius-selector: 1rem;8186+ --radius-selector: 0rem;
8240 --radius-field: 0rem;8187 --radius-field: 0rem;
8241- --radius-box: 0.25rem;8188+ --radius-box: 0rem;
8242 --size-selector: 0.25rem;8189 --size-selector: 0.25rem;
8243 --size-field: 0.25rem;8190 --size-field: 0.25rem;
8244 --border: 1px;8191 --border: 1px;
M internal/webui/public/assets/js/custom.js +12 -6
@@ -36,18 +36,19 @@ function initializeVersionCheck() {
3636 const init = () => {
3737 const currentVersionElement = document.getElementById("actual-version");
3838 if (!currentVersionElement) {
39- console.debug("Error: #actual-version element not found.");
40- return;
39+ console.debug("Error: #actual-version element not found. Assuming not on admin page.");
40+ return false; // Indicate element not found
4141 }
4242 const currentVersion = currentVersionElement.textContent.trim();
4343 if (!currentVersion) {
4444 console.debug("Dev mode detected, skipping version check.");
45- return;
45+ return true; // Element found, but dev mode
4646 }
4747 fetchVersionInfo(currentVersion);
48+ return true; // Indicate element found
4849 };
4950
50- init();
51+ return init(); // Return the result of init
5152 }
5253
5354 // Initialize jQuery functionality for the admin dashboard
@@ -391,6 +392,11 @@ document.addEventListener("DOMContentLoaded", function() {
391392 console.debug('jQuery is loaded successfully');
392393 }
393394
394- initializeVersionCheck();
395- initializeAdminDashboard();
395+ // Run version check (which also checks if we're on an admin page)
396+ const isAdminPage = initializeVersionCheck();
397+
398+ // Only initialize admin dashboard functions if jQuery is loaded AND we are on an admin page
399+ if (typeof $ !== 'undefined' && isAdminPage) {
400+ initializeAdminDashboard();
401+ }
396402 });
@@ -36,18 +36,19 @@ function initializeVersionCheck() {
36 const init = () => {36 const init = () => {
37 const currentVersionElement = document.getElementById("actual-version");37 const currentVersionElement = document.getElementById("actual-version");
38 if (!currentVersionElement) {38 if (!currentVersionElement) {
39- console.debug("Error: #actual-version element not found.");39+ console.debug("Error: #actual-version element not found. Assuming not on admin page.");
40- return;40+ return false; // Indicate element not found
41 }41 }
42 const currentVersion = currentVersionElement.textContent.trim();42 const currentVersion = currentVersionElement.textContent.trim();
43 if (!currentVersion) {43 if (!currentVersion) {
44 console.debug("Dev mode detected, skipping version check.");44 console.debug("Dev mode detected, skipping version check.");
45- return;45+ return true; // Element found, but dev mode
46 }46 }
47 fetchVersionInfo(currentVersion);47 fetchVersionInfo(currentVersion);
48+ return true; // Indicate element found
48 };49 };
49 50
50- init();51+ return init(); // Return the result of init
51 }52 }
52 53
53 // Initialize jQuery functionality for the admin dashboard54 // Initialize jQuery functionality for the admin dashboard
@@ -391,6 +392,11 @@ document.addEventListener("DOMContentLoaded", function() {
391 console.debug('jQuery is loaded successfully');392 console.debug('jQuery is loaded successfully');
392 }393 }
393 394
394- initializeVersionCheck();395+ // Run version check (which also checks if we're on an admin page)
395- initializeAdminDashboard();396+ const isAdminPage = initializeVersionCheck();
397+
398+ // Only initialize admin dashboard functions if jQuery is loaded AND we are on an admin page
399+ if (typeof $ !== 'undefined' && isAdminPage) {
400+ initializeAdminDashboard();
401+ }
396 });402 });
D pkg/htmx/htmlfragments.go +0 -42
@@ -1,42 +0,0 @@
1-package htmx
2-
3-import (
4- "bytes"
5- "fmt"
6-
7- "github.com/PuerkitoBio/goquery"
8- "github.com/bnema/gordon/internal/server"
9- "github.com/bnema/gordon/internal/templating/render"
10-)
11-
12-// GetHTMLFragmentByID returns the HTML fragment with the specified id
13-func GetHTMLFragmentByID(id string, data interface{}, a *server.App) (string, error) {
14- // 1. Render the template
15- renderer, err := render.GetHTMLRenderer("html/fragments", "components.gohtml", a.TemplateFS, a)
16- if err != nil {
17- return "", fmt.Errorf("failed to get renderer: %w", err)
18- }
19-
20- renderedHTML, err := renderer.Render(data, a)
21- if err != nil {
22- return "", fmt.Errorf("failed to render template: %w", err)
23- }
24-
25- // 2. Parse the rendered HTML with goquery
26- doc, err := goquery.NewDocumentFromReader(bytes.NewReader([]byte(renderedHTML)))
27- if err != nil {
28- return "", fmt.Errorf("failed to parse rendered HTML: %w", err)
29- }
30-
31- // 3. Find the div with the specified id
32- fragmentContent, err := doc.Find("#" + id).Html()
33- if err != nil {
34- return "", fmt.Errorf("failed to find fragment with id %s: %w", id, err)
35- }
36-
37- if fragmentContent == "" {
38- return "", fmt.Errorf("failed to find fragment with id %s: %w", id, err)
39- }
40-
41- return fragmentContent, nil
42-}
@@ -1,42 +0,0 @@
1-package htmx
2-
3-import (
4- "bytes"
5- "fmt"
6-
7- "github.com/PuerkitoBio/goquery"
8- "github.com/bnema/gordon/internal/server"
9- "github.com/bnema/gordon/internal/templating/render"
10-)
11-
12-// GetHTMLFragmentByID returns the HTML fragment with the specified id
13-func GetHTMLFragmentByID(id string, data interface{}, a *server.App) (string, error) {
14- // 1. Render the template
15- renderer, err := render.GetHTMLRenderer("html/fragments", "components.gohtml", a.TemplateFS, a)
16- if err != nil {
17- return "", fmt.Errorf("failed to get renderer: %w", err)
18- }
19-
20- renderedHTML, err := renderer.Render(data, a)
21- if err != nil {
22- return "", fmt.Errorf("failed to render template: %w", err)
23- }
24-
25- // 2. Parse the rendered HTML with goquery
26- doc, err := goquery.NewDocumentFromReader(bytes.NewReader([]byte(renderedHTML)))
27- if err != nil {
28- return "", fmt.Errorf("failed to parse rendered HTML: %w", err)
29- }
30-
31- // 3. Find the div with the specified id
32- fragmentContent, err := doc.Find("#" + id).Html()
33- if err != nil {
34- return "", fmt.Errorf("failed to find fragment with id %s: %w", id, err)
35- }
36-
37- if fragmentContent == "" {
38- return "", fmt.Errorf("failed to find fragment with id %s: %w", id, err)
39- }
40-
41- return fragmentContent, nil
42-}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close