591 lines
18 KiB
Go
591 lines
18 KiB
Go
package handlers
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"database/sql"
|
|
"fmt"
|
|
"html/template"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/labstack/echo/v4"
|
|
)
|
|
|
|
// AdminAuth returns middleware that validates the API key from either
|
|
// the X-API-Key header or the ?key= query parameter, making the admin
|
|
// UI accessible from a browser.
|
|
func AdminAuth(key string) echo.MiddlewareFunc {
|
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
provided := c.Request().Header.Get("X-API-Key")
|
|
if provided == "" {
|
|
provided = c.QueryParam("key")
|
|
}
|
|
if provided == "" {
|
|
return c.HTML(http.StatusUnauthorized, unauthorizedHTML)
|
|
}
|
|
if subtle.ConstantTimeCompare([]byte(provided), []byte(key)) != 1 {
|
|
return c.HTML(http.StatusUnauthorized, unauthorizedHTML)
|
|
}
|
|
// Store key so templates can propagate it in links.
|
|
c.Set("api_key", provided)
|
|
return next(c)
|
|
}
|
|
}
|
|
}
|
|
|
|
const unauthorizedHTML = `<!DOCTYPE html>
|
|
<html><head><title>Unauthorized</title>` + cssStyle + `</head>
|
|
<body>
|
|
<div class="container" style="margin-top:80px;text-align:center">
|
|
<h1>Unauthorized</h1>
|
|
<p style="margin-top:12px;color:#666">Provide your API key as a <code>?key=</code> query parameter.</p>
|
|
</div>
|
|
</body></html>`
|
|
|
|
// knownTables is the ordered list of tables exposed in the admin UI.
|
|
var knownTables = []string{
|
|
"players",
|
|
"player_ips",
|
|
"game_sessions",
|
|
"device_infos",
|
|
"high_scores",
|
|
}
|
|
|
|
type columnInfo struct {
|
|
Name string
|
|
PK bool
|
|
}
|
|
|
|
func getColumns(db *sql.DB, table string) ([]columnInfo, error) {
|
|
rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var cols []columnInfo
|
|
for rows.Next() {
|
|
var cid int
|
|
var name, typ string
|
|
var notNull, pk int
|
|
var dflt sql.NullString
|
|
if err := rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk); err != nil {
|
|
return nil, err
|
|
}
|
|
cols = append(cols, columnInfo{Name: name, PK: pk == 1})
|
|
}
|
|
return cols, rows.Err()
|
|
}
|
|
|
|
func getPKColumn(cols []columnInfo) string {
|
|
for _, c := range cols {
|
|
if c.PK {
|
|
return c.Name
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func isKnownTable(name string) bool {
|
|
for _, t := range knownTables {
|
|
if t == name {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// AdminDashboard handles GET /admin/ — lists all tables with row counts.
|
|
func AdminDashboard(db *sql.DB) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
type tableInfo struct {
|
|
Name string
|
|
Count int
|
|
}
|
|
var tables []tableInfo
|
|
for _, t := range knownTables {
|
|
var count int
|
|
row := db.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM %s", t))
|
|
if err := row.Scan(&count); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, echo.Map{"error": "database error"})
|
|
}
|
|
tables = append(tables, tableInfo{Name: t, Count: count})
|
|
}
|
|
|
|
data := map[string]interface{}{
|
|
"Tables": tables,
|
|
}
|
|
return renderTemplate(c, dashboardTmpl, data)
|
|
}
|
|
}
|
|
|
|
// AdminTable handles GET /admin/tables/:name/ — paginated table view.
|
|
func AdminTable(db *sql.DB) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
table := c.Param("name")
|
|
if !isKnownTable(table) {
|
|
return c.JSON(http.StatusNotFound, echo.Map{"error": "table not found"})
|
|
}
|
|
|
|
cols, err := getColumns(db, table)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, echo.Map{"error": "database error"})
|
|
}
|
|
if len(cols) == 0 {
|
|
return c.JSON(http.StatusNotFound, echo.Map{"error": "table not found"})
|
|
}
|
|
|
|
// Pagination.
|
|
page, _ := strconv.Atoi(c.QueryParam("page"))
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
perPage := 50
|
|
offset := (page - 1) * perPage
|
|
|
|
// Total count.
|
|
var total int
|
|
if err := db.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM %s", table)).Scan(&total); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, echo.Map{"error": "database error"})
|
|
}
|
|
|
|
// Sort.
|
|
sortCol := c.QueryParam("sort")
|
|
sortDir := c.QueryParam("dir")
|
|
orderClause := ""
|
|
validSort := false
|
|
for _, col := range cols {
|
|
if col.Name == sortCol {
|
|
validSort = true
|
|
break
|
|
}
|
|
}
|
|
if validSort {
|
|
dir := "ASC"
|
|
if strings.EqualFold(sortDir, "desc") {
|
|
dir = "DESC"
|
|
}
|
|
orderClause = fmt.Sprintf(" ORDER BY %s %s", sortCol, dir)
|
|
} else {
|
|
// Default: order by primary key descending.
|
|
pk := getPKColumn(cols)
|
|
if pk != "" {
|
|
orderClause = fmt.Sprintf(" ORDER BY %s DESC", pk)
|
|
sortCol = pk
|
|
sortDir = "desc"
|
|
}
|
|
}
|
|
|
|
// Search filter.
|
|
search := strings.TrimSpace(c.QueryParam("q"))
|
|
whereClause := ""
|
|
var args []interface{}
|
|
if search != "" {
|
|
var conditions []string
|
|
for _, col := range cols {
|
|
conditions = append(conditions, fmt.Sprintf("CAST(%s AS TEXT) LIKE ?", col.Name))
|
|
args = append(args, "%"+search+"%")
|
|
}
|
|
whereClause = " WHERE " + strings.Join(conditions, " OR ")
|
|
|
|
// Recount with filter.
|
|
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM %s%s", table, whereClause)
|
|
if err := db.QueryRow(countQuery, args...).Scan(&total); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, echo.Map{"error": "database error"})
|
|
}
|
|
}
|
|
|
|
query := fmt.Sprintf("SELECT * FROM %s%s%s LIMIT ? OFFSET ?", table, whereClause, orderClause)
|
|
queryArgs := append(args, perPage, offset)
|
|
rows, err := db.Query(query, queryArgs...)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, echo.Map{"error": "database error"})
|
|
}
|
|
defer rows.Close()
|
|
|
|
var rowData [][]string
|
|
colNames, _ := rows.Columns()
|
|
for rows.Next() {
|
|
vals := make([]interface{}, len(colNames))
|
|
ptrs := make([]interface{}, len(colNames))
|
|
for i := range vals {
|
|
ptrs[i] = &vals[i]
|
|
}
|
|
if err := rows.Scan(ptrs...); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, echo.Map{"error": "scan error"})
|
|
}
|
|
row := make([]string, len(colNames))
|
|
for i, v := range vals {
|
|
if v == nil {
|
|
row[i] = "NULL"
|
|
} else {
|
|
row[i] = fmt.Sprintf("%v", v)
|
|
}
|
|
}
|
|
rowData = append(rowData, row)
|
|
}
|
|
|
|
totalPages := (total + perPage - 1) / perPage
|
|
pk := getPKColumn(cols)
|
|
|
|
// Find PK column index for linking.
|
|
pkIdx := -1
|
|
for i, col := range cols {
|
|
if col.Name == pk {
|
|
pkIdx = i
|
|
break
|
|
}
|
|
}
|
|
|
|
data := map[string]interface{}{
|
|
"Table": table,
|
|
"Columns": colNames,
|
|
"Rows": rowData,
|
|
"Page": page,
|
|
"TotalPages": totalPages,
|
|
"Total": total,
|
|
"PerPage": perPage,
|
|
"Sort": sortCol,
|
|
"Dir": sortDir,
|
|
"Search": search,
|
|
"PKIndex": pkIdx,
|
|
"Tables": knownTables,
|
|
}
|
|
return renderTemplate(c, tableTmpl, data)
|
|
}
|
|
}
|
|
|
|
// AdminRow handles GET /admin/tables/:name/:pk/ — single row detail.
|
|
func AdminRow(db *sql.DB) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
table := c.Param("name")
|
|
pkVal := c.Param("pk")
|
|
if !isKnownTable(table) {
|
|
return c.JSON(http.StatusNotFound, echo.Map{"error": "table not found"})
|
|
}
|
|
|
|
cols, err := getColumns(db, table)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, echo.Map{"error": "database error"})
|
|
}
|
|
|
|
pk := getPKColumn(cols)
|
|
if pk == "" {
|
|
return c.JSON(http.StatusInternalServerError, echo.Map{"error": "no primary key"})
|
|
}
|
|
|
|
query := fmt.Sprintf("SELECT * FROM %s WHERE %s = ?", table, pk)
|
|
rows, err := db.Query(query, pkVal)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, echo.Map{"error": "database error"})
|
|
}
|
|
defer rows.Close()
|
|
|
|
colNames, _ := rows.Columns()
|
|
if !rows.Next() {
|
|
return c.JSON(http.StatusNotFound, echo.Map{"error": "row not found"})
|
|
}
|
|
|
|
vals := make([]interface{}, len(colNames))
|
|
ptrs := make([]interface{}, len(colNames))
|
|
for i := range vals {
|
|
ptrs[i] = &vals[i]
|
|
}
|
|
if err := rows.Scan(ptrs...); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, echo.Map{"error": "scan error"})
|
|
}
|
|
|
|
type field struct {
|
|
Name string
|
|
Value string
|
|
}
|
|
var fields []field
|
|
for i, name := range colNames {
|
|
v := "NULL"
|
|
if vals[i] != nil {
|
|
v = fmt.Sprintf("%v", vals[i])
|
|
}
|
|
fields = append(fields, field{Name: name, Value: v})
|
|
}
|
|
|
|
data := map[string]interface{}{
|
|
"Table": table,
|
|
"PK": pkVal,
|
|
"Fields": fields,
|
|
"Tables": knownTables,
|
|
}
|
|
return renderTemplate(c, rowTmpl, data)
|
|
}
|
|
}
|
|
|
|
func renderTemplate(c echo.Context, tmpl *template.Template, data interface{}) error {
|
|
// Inject the API key so templates can propagate it in links.
|
|
if m, ok := data.(map[string]interface{}); ok {
|
|
if key, exists := c.Get("api_key").(string); exists {
|
|
m["Key"] = key
|
|
}
|
|
}
|
|
c.Response().Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
c.Response().WriteHeader(http.StatusOK)
|
|
return tmpl.Execute(c.Response().Writer, data)
|
|
}
|
|
|
|
// --- HTML Templates ---
|
|
|
|
var baseFuncs = template.FuncMap{
|
|
"add": func(a, b int) int { return a + b },
|
|
"sub": func(a, b int) int { return a - b },
|
|
"toggleDir": func(dir string) string {
|
|
if strings.EqualFold(dir, "asc") {
|
|
return "desc"
|
|
}
|
|
return "asc"
|
|
},
|
|
"seq": func(start, end int) []int {
|
|
var s []int
|
|
for i := start; i <= end; i++ {
|
|
s = append(s, i)
|
|
}
|
|
return s
|
|
},
|
|
"min": func(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
},
|
|
"max": func(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
},
|
|
"eq": func(a, b interface{}) bool {
|
|
return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b)
|
|
},
|
|
"kp": func(key string) template.URL {
|
|
if key == "" {
|
|
return ""
|
|
}
|
|
return template.URL("key=" + key)
|
|
},
|
|
"kpAmp": func(key string) template.URL {
|
|
if key == "" {
|
|
return ""
|
|
}
|
|
return template.URL("&key=" + key)
|
|
},
|
|
}
|
|
|
|
const cssStyle = `
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; }
|
|
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
|
|
h1 { margin-bottom: 20px; font-size: 24px; color: #111; }
|
|
h2 { margin-bottom: 16px; font-size: 20px; color: #111; }
|
|
a { color: #0066cc; text-decoration: none; }
|
|
a:hover { text-decoration: underline; }
|
|
|
|
/* Navigation */
|
|
.nav { background: #1a1a2e; padding: 12px 20px; margin-bottom: 24px; }
|
|
.nav a { color: #e0e0e0; margin-right: 16px; font-size: 14px; }
|
|
.nav a:hover { color: #fff; text-decoration: none; }
|
|
.nav a.active { color: #fff; font-weight: 600; }
|
|
.nav .brand { color: #fff; font-weight: 700; font-size: 16px; margin-right: 32px; }
|
|
|
|
/* Dashboard cards */
|
|
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; }
|
|
.card { background: #fff; border-radius: 8px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); transition: box-shadow 0.2s; }
|
|
.card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
|
|
.card h3 { font-size: 16px; margin-bottom: 8px; }
|
|
.card .count { font-size: 28px; font-weight: 700; color: #0066cc; }
|
|
.card a { display: block; color: inherit; }
|
|
.card a:hover { text-decoration: none; }
|
|
|
|
/* Table */
|
|
.table-wrap { background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); overflow-x: auto; }
|
|
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
|
th { background: #f8f9fa; text-align: left; padding: 10px 12px; border-bottom: 2px solid #dee2e6; white-space: nowrap; position: sticky; top: 0; }
|
|
th a { color: #333; }
|
|
th .sort-arrow { font-size: 11px; margin-left: 4px; color: #999; }
|
|
th .sort-arrow.active { color: #0066cc; }
|
|
td { padding: 8px 12px; border-bottom: 1px solid #eee; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
tr:hover td { background: #f8f9fa; }
|
|
td.null { color: #999; font-style: italic; }
|
|
|
|
/* Toolbar */
|
|
.toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; flex-wrap: wrap; gap: 12px; }
|
|
.toolbar .info { font-size: 14px; color: #666; }
|
|
.search-form { display: flex; gap: 8px; }
|
|
.search-form input { padding: 6px 12px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px; width: 260px; }
|
|
.search-form button { padding: 6px 16px; background: #0066cc; color: #fff; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; }
|
|
.search-form button:hover { background: #0055aa; }
|
|
|
|
/* Pagination */
|
|
.pagination { display: flex; justify-content: center; gap: 4px; margin-top: 16px; }
|
|
.pagination a, .pagination span { padding: 6px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
|
|
.pagination a:hover { background: #e9ecef; text-decoration: none; }
|
|
.pagination .current { background: #0066cc; color: #fff; border-color: #0066cc; }
|
|
.pagination .disabled { color: #ccc; pointer-events: none; }
|
|
|
|
/* Detail view */
|
|
.detail-table { width: 100%; }
|
|
.detail-table th { width: 200px; background: #f8f9fa; font-weight: 600; vertical-align: top; }
|
|
.detail-table td { word-break: break-all; white-space: normal; }
|
|
</style>
|
|
`
|
|
|
|
var dashboardTmpl = template.Must(template.New("dashboard").Funcs(baseFuncs).Parse(`<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Horchposten Admin</title>` + cssStyle + `</head>
|
|
<body>
|
|
<div class="nav">
|
|
<span class="brand">Horchposten</span>
|
|
<a href="/admin/?{{kp .Key}}" class="active">Dashboard</a>
|
|
</div>
|
|
<div class="container">
|
|
<h1>Database</h1>
|
|
<div class="cards">
|
|
{{range .Tables}}
|
|
<div class="card">
|
|
<a href="/admin/tables/{{.Name}}/?{{kp $.Key}}">
|
|
<h3>{{.Name}}</h3>
|
|
<div class="count">{{.Count}}</div>
|
|
<div style="font-size:13px;color:#666;margin-top:4px">rows</div>
|
|
</a>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>`))
|
|
|
|
var tableTmpl = template.Must(template.New("table").Funcs(baseFuncs).Parse(`<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>{{.Table}} — Horchposten Admin</title>` + cssStyle + `</head>
|
|
<body>
|
|
<div class="nav">
|
|
<span class="brand">Horchposten</span>
|
|
<a href="/admin/?{{kp .Key}}">Dashboard</a>
|
|
{{range .Tables}}<a href="/admin/tables/{{.}}/?{{kp $.Key}}"{{if eq . $.Table}} class="active"{{end}}>{{.}}</a>{{end}}
|
|
</div>
|
|
<div class="container">
|
|
<h2>{{.Table}}</h2>
|
|
|
|
<div class="toolbar">
|
|
<div class="info">{{.Total}} rows total — page {{.Page}} of {{.TotalPages}}</div>
|
|
<form class="search-form" method="get" action="/admin/tables/{{.Table}}/">
|
|
<input type="hidden" name="key" value="{{.Key}}">
|
|
<input type="text" name="q" placeholder="Search all columns..." value="{{.Search}}">
|
|
<button type="submit">Search</button>
|
|
{{if .Search}}<a href="/admin/tables/{{.Table}}/?{{kp .Key}}" style="padding:6px 12px;font-size:14px">Clear</a>{{end}}
|
|
</form>
|
|
</div>
|
|
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
{{range $i, $col := .Columns}}
|
|
<th>
|
|
{{if eq $col $.Sort}}
|
|
<a href="?sort={{$col}}&dir={{toggleDir $.Dir}}{{if $.Search}}&q={{$.Search}}{{end}}{{kpAmp $.Key}}">
|
|
{{$col}} <span class="sort-arrow active">{{if eq $.Dir "asc"}}▲{{else}}▼{{end}}</span>
|
|
</a>
|
|
{{else}}
|
|
<a href="?sort={{$col}}&dir=asc{{if $.Search}}&q={{$.Search}}{{end}}{{kpAmp $.Key}}">
|
|
{{$col}} <span class="sort-arrow">▲</span>
|
|
</a>
|
|
{{end}}
|
|
</th>
|
|
{{end}}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{{if not .Rows}}
|
|
<tr><td colspan="{{len .Columns}}" style="text-align:center;padding:24px;color:#999">No rows found</td></tr>
|
|
{{end}}
|
|
{{range .Rows}}
|
|
<tr>
|
|
{{range $i, $val := .}}
|
|
{{if and (ge $.PKIndex 0) (eq $i $.PKIndex)}}
|
|
<td><a href="/admin/tables/{{$.Table}}/{{$val}}/?{{kp $.Key}}">{{$val}}</a></td>
|
|
{{else if eq $val "NULL"}}
|
|
<td class="null">NULL</td>
|
|
{{else}}
|
|
<td>{{$val}}</td>
|
|
{{end}}
|
|
{{end}}
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{{if gt .TotalPages 1}}
|
|
<div class="pagination">
|
|
{{if gt .Page 1}}
|
|
<a href="?page={{sub .Page 1}}&sort={{.Sort}}&dir={{.Dir}}{{if .Search}}&q={{.Search}}{{end}}{{kpAmp .Key}}">← Prev</a>
|
|
{{else}}
|
|
<span class="disabled">← Prev</span>
|
|
{{end}}
|
|
|
|
{{$start := max 1 (sub .Page 2)}}
|
|
{{$end := min .TotalPages (add .Page 2)}}
|
|
{{if gt $start 1}}<a href="?page=1&sort={{.Sort}}&dir={{.Dir}}{{if .Search}}&q={{.Search}}{{end}}{{kpAmp .Key}}">1</a>{{if gt $start 2}}<span>...</span>{{end}}{{end}}
|
|
|
|
{{range seq $start $end}}
|
|
{{if eq . $.Page}}
|
|
<span class="current">{{.}}</span>
|
|
{{else}}
|
|
<a href="?page={{.}}&sort={{$.Sort}}&dir={{$.Dir}}{{if $.Search}}&q={{$.Search}}{{end}}{{kpAmp $.Key}}">{{.}}</a>
|
|
{{end}}
|
|
{{end}}
|
|
|
|
{{if lt $end $.TotalPages}}{{if lt (add $end 1) $.TotalPages}}<span>...</span>{{end}}<a href="?page={{$.TotalPages}}&sort={{.Sort}}&dir={{.Dir}}{{if .Search}}&q={{.Search}}{{end}}{{kpAmp .Key}}">{{$.TotalPages}}</a>{{end}}
|
|
|
|
{{if lt .Page .TotalPages}}
|
|
<a href="?page={{add .Page 1}}&sort={{.Sort}}&dir={{.Dir}}{{if .Search}}&q={{.Search}}{{end}}{{kpAmp .Key}}">Next →</a>
|
|
{{else}}
|
|
<span class="disabled">Next →</span>
|
|
{{end}}
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
</body>
|
|
</html>`))
|
|
|
|
var rowTmpl = template.Must(template.New("row").Funcs(baseFuncs).Parse(`<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>{{.Table}} #{{.PK}} — Horchposten Admin</title>` + cssStyle + `</head>
|
|
<body>
|
|
<div class="nav">
|
|
<span class="brand">Horchposten</span>
|
|
<a href="/admin/?{{kp .Key}}">Dashboard</a>
|
|
{{range .Tables}}<a href="/admin/tables/{{.}}/?{{kp $.Key}}"{{if eq . $.Table}} class="active"{{end}}>{{.}}</a>{{end}}
|
|
</div>
|
|
<div class="container">
|
|
<h2><a href="/admin/tables/{{.Table}}/?{{kp .Key}}">{{.Table}}</a> › {{.PK}}</h2>
|
|
<div class="table-wrap">
|
|
<table class="detail-table">
|
|
<tbody>
|
|
{{range .Fields}}
|
|
<tr>
|
|
<th>{{.Name}}</th>
|
|
{{if eq .Value "NULL"}}
|
|
<td class="null">NULL</td>
|
|
{{else}}
|
|
<td>{{.Value}}</td>
|
|
{{end}}
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>`))
|