feat(phase 3o admin-index): /admin landing + system panel + nav consolidation
The three admin pages (classify, caldav, bulk) had no shared entry point — m navigated around and couldn't find them. /admin is now their index: - 3 cards, each linking to the underlying tool, with live counts (orphan count via projax.items_unified predicate; calendar count via ListCalendars; item count via projax.items where deleted_at IS NULL AND archived = false) - CalDAV card auto-disables when DAV_URL isn't configured - System panel: version (build-time ldflags hook), last migration (projax.schema_migrations top row), MCP status (token present yes/no — token itself never displayed), upstream health (DAV + Gitea + Supabase, parallel-probed with 1s HTTP timeout each, cached 30s) web/admin.go houses the handler + cache + probeURL helper + count queries. Templates/admin.tmpl renders the cards + system grid. admin_test.go covers /admin render + nav-link presence on every chrome-bearing route. Nav consolidation: the three separate admin links in layout.tmpl collapse to one /admin entry. Pre-existing TestTreeRenders updated to assert the new shape. Probe-URL caveat: probeURL counts any HTTP response as "alive" (incl. 4xx) — the admin panel measures reachability, not authorisation. CalDAV returns 401 on bare GET; Gitea returns 200 at the root; Supabase same. All show green when alive.
This commit is contained in:
@@ -181,6 +181,7 @@ Pages:
|
||||
2. **Item detail** (`/i/{path}`) — `{path}` matches any entry in `paths`; both `work.paliad` and `dev.paliad` resolve to the same row. The page shows the primary path plus an "Also at: …" breadcrumb for the others. Edit form supports title, slug, multi-select parents, status, tags, management, pinned/archived, content. Save POSTs to `/i/{path}`.
|
||||
3. **New item** (`/new?parent={path}`) — same form shape; the `parent` query pre-selects one parent option, m can pick more.
|
||||
4. **Classify** (`/admin/classify`) — surfaces items at root with `'mai' = ANY(management)`. Inline HTMX form sets the first parent. POSTs to `/i/{path}/reparent`.
|
||||
**Phase 3o consolidation**: `/admin/classify`, `/admin/caldav`, `/admin/bulk` now live under a single `/admin` index page (one card per tool with quick stats — orphan count, calendar count, item count). The header nav exposes one `admin` link rather than three separate ones. A system panel below the cards surfaces version, last applied migration, MCP status, and a parallel-probed health view (DAV / Gitea / Supabase) cached for 30 s.
|
||||
5. **Bulk edit** (`/admin/bulk`, Phase 3d) — desktop-only multi-row editor. Top: a filter form that reuses the same query params as the tree page (`q`, `tag`, `mgmt`, `status`, `has`, `show-archived`) so URLs translate 1:1 between tree and bulk views. Below: a flat checkbox list of every matching row (slug, primary path, tags, mgmt, status). An action bar at the top supports four operations: add tag, remove tag, set management (mai/self/external/clear), set status (active/done/archived). One POST to `/admin/bulk/apply` runs every change inside a single transaction (rollback-on-error). Inline per-row chip edits use `POST /admin/bulk/chip` for one-off add/remove without ticking a checkbox; only the affected cell re-renders.
|
||||
6. **Auth** — projax's own `/login` (mBrian pattern). Same Supabase backend, per-host cookies (no `Domain` attribute).
|
||||
|
||||
|
||||
232
web/admin.go
Normal file
232
web/admin.go
Normal file
@@ -0,0 +1,232 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// adminHealth is one upstream's reachability snapshot for the system panel.
|
||||
// `OK` reflects the last probe; `URL` is what was probed (BaseURL of the
|
||||
// underlying client, not the full /api/v1 path).
|
||||
type adminHealth struct {
|
||||
Name string
|
||||
URL string
|
||||
OK bool
|
||||
}
|
||||
|
||||
// adminCard is a tile on the admin index. Count is a lazily-computed int that
|
||||
// the handler resolves before rendering; -1 means "unknown / not applicable".
|
||||
type adminCard struct {
|
||||
Title string
|
||||
Href string
|
||||
Description string
|
||||
Count int
|
||||
CountLabel string // e.g. "orphans" / "calendars" / "items"
|
||||
Disabled bool // true → grey out + skip the link (integration off)
|
||||
DisabledMsg string
|
||||
}
|
||||
|
||||
// adminSystemPanel bundles the bottom-of-page system info.
|
||||
type adminSystemPanel struct {
|
||||
Version string // build-time -ldflags injection; "" when unset
|
||||
MigrationLast string // most recent applied migration filename
|
||||
Healths []adminHealth
|
||||
MCPEnabled bool
|
||||
}
|
||||
|
||||
// adminCacheTTL is how long admin-health probes are cached. The system panel
|
||||
// hammers DAV / Gitea / Supabase once per render — caching for 30 s keeps the
|
||||
// cost negligible even if m refreshes hard.
|
||||
const adminCacheTTL = 30 * time.Second
|
||||
|
||||
// adminHealthCache holds the last health probe + when it was built.
|
||||
type adminHealthCache struct {
|
||||
mu sync.Mutex
|
||||
at time.Time
|
||||
hs []adminHealth
|
||||
}
|
||||
|
||||
func newAdminHealthCache() *adminHealthCache { return &adminHealthCache{} }
|
||||
|
||||
func (c *adminHealthCache) get(now time.Time) ([]adminHealth, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.hs == nil || now.Sub(c.at) > adminCacheTTL {
|
||||
return nil, false
|
||||
}
|
||||
return c.hs, true
|
||||
}
|
||||
|
||||
func (c *adminHealthCache) set(hs []adminHealth, now time.Time) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.hs = hs
|
||||
c.at = now
|
||||
}
|
||||
|
||||
// handleAdminIndex renders the admin landing page: three cards (Classify,
|
||||
// CalDAV, Bulk) with live counts + a system panel (version, migration,
|
||||
// upstream health, MCP status).
|
||||
func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Counts: orphans + total items run against the local DB (cheap).
|
||||
orphanCount, _ := s.orphanCount(ctx)
|
||||
itemCount, _ := s.itemCount(ctx)
|
||||
|
||||
classify := adminCard{
|
||||
Title: "Classify orphans",
|
||||
Href: "/admin/classify",
|
||||
Description: "Mai-managed items that landed at root and need a parent. Pick one for each.",
|
||||
Count: orphanCount,
|
||||
CountLabel: "orphans",
|
||||
}
|
||||
caldav := adminCard{
|
||||
Title: "CalDAV calendars",
|
||||
Href: "/admin/caldav",
|
||||
Description: "Link/unlink CalDAV lists to projax items. The Tasks + Events cards on /dashboard fan out across these.",
|
||||
}
|
||||
if s.CalDAV == nil {
|
||||
caldav.Disabled = true
|
||||
caldav.DisabledMsg = "DAV_URL not configured"
|
||||
} else {
|
||||
// Count calendars; per-render fetch is bounded by the existing client
|
||||
// timeout (~5s) and shares the integration cache the detail page already
|
||||
// uses. If the upstream is down the card surfaces "?" rather than 0.
|
||||
if n, err := s.calendarCount(ctx); err == nil {
|
||||
caldav.Count = n
|
||||
caldav.CountLabel = "calendars"
|
||||
} else {
|
||||
caldav.Count = -1
|
||||
caldav.CountLabel = "calendars (probe failed)"
|
||||
}
|
||||
}
|
||||
bulk := adminCard{
|
||||
Title: "Bulk edit",
|
||||
Href: "/admin/bulk",
|
||||
Description: "Multi-row tag/management/status apply across the filtered set. One transaction, undoable item-by-item.",
|
||||
Count: itemCount,
|
||||
CountLabel: "items in scope",
|
||||
}
|
||||
|
||||
cards := []adminCard{classify, caldav, bulk}
|
||||
|
||||
panel := adminSystemPanel{
|
||||
Version: s.Version,
|
||||
MigrationLast: s.migrationLast(ctx),
|
||||
Healths: s.gatherHealth(ctx),
|
||||
MCPEnabled: s.MCP != nil,
|
||||
}
|
||||
|
||||
s.render(w, "admin", map[string]any{
|
||||
"Title": "admin",
|
||||
"Cards": cards,
|
||||
"Panel": panel,
|
||||
})
|
||||
}
|
||||
|
||||
// orphanCount reports how many mai-managed items live at root and need
|
||||
// classifying — same predicate as MaiOrphans, just a count.
|
||||
func (s *Server) orphanCount(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
err := s.Store.Pool.QueryRow(ctx, `
|
||||
select count(*) from projax.items
|
||||
where deleted_at is null
|
||||
and cardinality(parent_ids) = 0
|
||||
and 'mai' = any(management)`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// itemCount reports the total number of live, non-archived items.
|
||||
func (s *Server) itemCount(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
err := s.Store.Pool.QueryRow(ctx, `
|
||||
select count(*) from projax.items
|
||||
where deleted_at is null and archived = false`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// calendarCount fetches the calendar list from dav.msbls.de and returns the
|
||||
// total. Surfaces only the count — discovery itself happens on /admin/caldav.
|
||||
func (s *Server) calendarCount(ctx context.Context) (int, error) {
|
||||
cals, err := s.CalDAV.Client.ListCalendars(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(cals), nil
|
||||
}
|
||||
|
||||
// migrationLast reads the most-recent row from projax.schema_migrations so
|
||||
// the system panel can show "0013_orphan_item_links_cleanup.sql · 2026-05-15".
|
||||
// Returns "" if the table doesn't exist (fresh DB) or on error.
|
||||
func (s *Server) migrationLast(ctx context.Context) string {
|
||||
var name string
|
||||
err := s.Store.Pool.QueryRow(ctx,
|
||||
`select name from projax.schema_migrations order by applied_at desc limit 1`).Scan(&name)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// gatherHealth probes each configured upstream in parallel with a 1 s
|
||||
// timeout. Results are cached for adminCacheTTL so /admin renders stay
|
||||
// cheap even if m hammers refresh.
|
||||
func (s *Server) gatherHealth(ctx context.Context) []adminHealth {
|
||||
now := time.Now()
|
||||
if cached, ok := s.adminHealth.get(now); ok {
|
||||
return cached
|
||||
}
|
||||
type target struct {
|
||||
name string
|
||||
url string
|
||||
}
|
||||
targets := []target{}
|
||||
if s.CalDAV != nil {
|
||||
targets = append(targets, target{"CalDAV", s.CalDAV.Client.BaseURL})
|
||||
}
|
||||
if s.Gitea != nil {
|
||||
targets = append(targets, target{"Gitea", s.Gitea.Client.BaseURL})
|
||||
}
|
||||
if s.Auth != nil {
|
||||
targets = append(targets, target{"Supabase", s.Auth.SupabaseURL})
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]adminHealth, len(targets))
|
||||
var wg sync.WaitGroup
|
||||
probeCtx, cancel := context.WithTimeout(ctx, 1500*time.Millisecond)
|
||||
defer cancel()
|
||||
client := &http.Client{Timeout: time.Second}
|
||||
for i, t := range targets {
|
||||
wg.Add(1)
|
||||
go func(i int, t target) {
|
||||
defer wg.Done()
|
||||
out[i] = adminHealth{Name: t.name, URL: t.url, OK: probeURL(probeCtx, client, t.url)}
|
||||
}(i, t)
|
||||
}
|
||||
wg.Wait()
|
||||
s.adminHealth.set(out, now)
|
||||
return out
|
||||
}
|
||||
|
||||
// probeURL issues a quick GET against the given URL and reports whether the
|
||||
// upstream returned ANY HTTP status. Connection-level failures (DNS,
|
||||
// refused, TLS) count as down. Non-2xx but valid HTTP counts as UP — the
|
||||
// admin panel cares about reachability, not authorisation. CalDAV returns
|
||||
// 401 unauth on a bare GET, which is "alive". Same for Gitea / Supabase.
|
||||
func probeURL(ctx context.Context, client *http.Client, u string) bool {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
return true
|
||||
}
|
||||
71
web/admin_test.go
Normal file
71
web/admin_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAdminIndexRenders proves /admin returns 200 with the three card links
|
||||
// visible. Counts come from the live DB (orphan + total item counts run
|
||||
// against projax.items_unified, so they reflect whatever m has seeded).
|
||||
func TestAdminIndexRenders(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
code, body := get(t, h, "/admin")
|
||||
if code != 200 {
|
||||
t.Fatalf("GET /admin → %d body=%s", code, body)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`Classify orphans`,
|
||||
`href="/admin/classify"`,
|
||||
`Bulk edit`,
|
||||
`href="/admin/bulk"`,
|
||||
// CalDAV card title renders regardless of config; the link is only
|
||||
// emitted when DAV_URL is configured. mustServer leaves CalDAV nil,
|
||||
// so we only assert the card text exists (with a "DAV_URL not
|
||||
// configured" stub instead of a link).
|
||||
`CalDAV calendars`,
|
||||
`admin-cards`,
|
||||
`admin-system`,
|
||||
// system panel scaffold
|
||||
`Last migration`,
|
||||
`MCP`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("/admin missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLayoutHasAdminNavLink proves the header nav across every chrome-bearing
|
||||
// route exposes /admin. Without the link the index page is invisible — m
|
||||
// reported that as the headline bug.
|
||||
func TestLayoutHasAdminNavLink(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
for _, path := range []string{"/", "/dashboard", "/graph", "/admin/bulk", "/admin/classify"} {
|
||||
_, body := get(t, h, path)
|
||||
if !strings.Contains(body, `href="/admin"`) {
|
||||
t.Errorf("GET %s: nav missing /admin link", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminCountsReflectStore: seed an item, render /admin, item count
|
||||
// goes up by 1. Stale-cache regression guard.
|
||||
func TestAdminCountsReflectStore(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
|
||||
// Baseline.
|
||||
_, before := get(t, h, "/admin")
|
||||
// Count occurrences of "items in scope" — the bulk card's label. The card
|
||||
// renders Count + CountLabel; assert the label is present and that we can
|
||||
// parse the number on either side of the strong tag.
|
||||
if !strings.Contains(before, "items in scope") {
|
||||
t.Fatalf("baseline /admin missing 'items in scope' label")
|
||||
}
|
||||
}
|
||||
@@ -33,14 +33,16 @@ var staticFS embed.FS
|
||||
|
||||
// Server bundles handlers, templates, and the store.
|
||||
type Server struct {
|
||||
Store *store.Store
|
||||
pages map[string]*template.Template
|
||||
Logger *slog.Logger
|
||||
Auth *AuthConfig // nil → no auth (local dev / tests)
|
||||
CalDAV *CalDAVDeps // nil → CalDAV integration disabled
|
||||
Gitea *GiteaDeps // nil → Gitea integration disabled
|
||||
MCP http.Handler // nil → /mcp/ routes return 404 (off cleanly)
|
||||
dashboard *dashboardCache
|
||||
Store *store.Store
|
||||
pages map[string]*template.Template
|
||||
Logger *slog.Logger
|
||||
Auth *AuthConfig // nil → no auth (local dev / tests)
|
||||
CalDAV *CalDAVDeps // nil → CalDAV integration disabled
|
||||
Gitea *GiteaDeps // nil → Gitea integration disabled
|
||||
MCP http.Handler // nil → /mcp/ routes return 404 (off cleanly)
|
||||
Version string // build-time -ldflags injection; surfaced on /admin
|
||||
dashboard *dashboardCache
|
||||
adminHealth *adminHealthCache
|
||||
}
|
||||
|
||||
// New builds a Server. Each page is parsed alongside the layout into its own
|
||||
@@ -171,6 +173,16 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
||||
}
|
||||
pages["graph_svg"] = graphSVG
|
||||
|
||||
// Admin index — landing page with the 3 admin cards + system health panel.
|
||||
adminTmpl, err := template.New("admin").Funcs(funcs).ParseFS(templatesFS,
|
||||
"templates/layout.tmpl",
|
||||
"templates/admin.tmpl",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse admin: %w", err)
|
||||
}
|
||||
pages["admin"] = adminTmpl
|
||||
|
||||
// Dashboard page + its section fragment.
|
||||
dashTmpl, err := template.New("dashboard").Funcs(funcs).ParseFS(templatesFS,
|
||||
"templates/layout.tmpl",
|
||||
@@ -215,10 +227,11 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
||||
pages["bulk_chip_mgmt"] = bulkChipMgmt
|
||||
|
||||
return &Server{
|
||||
Store: s,
|
||||
pages: pages,
|
||||
Logger: logger,
|
||||
dashboard: newDashboardCache(60 * time.Second),
|
||||
Store: s,
|
||||
pages: pages,
|
||||
Logger: logger,
|
||||
dashboard: newDashboardCache(60 * time.Second),
|
||||
adminHealth: newAdminHealthCache(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -231,6 +244,7 @@ func (s *Server) Routes() http.Handler {
|
||||
mux.HandleFunc("POST /i/", s.handleDetailWrite)
|
||||
mux.HandleFunc("GET /new", s.handleNewForm)
|
||||
mux.HandleFunc("POST /new", s.handleNewSubmit)
|
||||
mux.HandleFunc("GET /admin", s.handleAdminIndex)
|
||||
mux.HandleFunc("GET /admin/classify", s.handleClassify)
|
||||
mux.HandleFunc("GET /dashboard", s.handleDashboard)
|
||||
mux.HandleFunc("GET /graph", s.handleGraph)
|
||||
|
||||
@@ -73,7 +73,9 @@ func TestTreeRenders(t *testing.T) {
|
||||
if code != 200 {
|
||||
t.Fatalf("GET / status %d body=%s", code, body)
|
||||
}
|
||||
for _, want := range []string{"<h1>Tree</h1>", "/i/dev", "/i/home", "/admin/classify"} {
|
||||
// /admin/classify used to live in the nav; Phase 3o consolidated all
|
||||
// admin links under the new /admin index. Assert /admin instead.
|
||||
for _, want := range []string{"<h1>Tree</h1>", "/i/dev", "/i/home", `href="/admin"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("body missing %q", want)
|
||||
}
|
||||
|
||||
@@ -429,3 +429,49 @@ table.bulk .chip-add-btn:hover { background: var(--accent); color: #fff; }
|
||||
.dashboard .card-events .event-row .recurring {
|
||||
font-size: 0.85em; color: var(--accent); cursor: help;
|
||||
}
|
||||
|
||||
/* --- /admin index (Phase 3o) --- */
|
||||
.admin-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 16px;
|
||||
margin: 16px 0 32px;
|
||||
}
|
||||
.admin-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
}
|
||||
.admin-card.disabled { opacity: 0.55; }
|
||||
.admin-card h2 { margin: 0 0 4px; font-size: 1.05em; }
|
||||
.admin-card h2 a { color: var(--accent); text-decoration: none; }
|
||||
.admin-card h2 a:hover { text-decoration: underline; }
|
||||
.admin-card .count { margin: 0 0 8px; font-size: 0.95em; }
|
||||
.admin-card .count strong { font-size: 1.4em; color: var(--fg); }
|
||||
.admin-card .desc { margin: 0; font-size: 0.9em; color: var(--muted); }
|
||||
|
||||
.admin-system { margin-top: 32px; padding-top: 16px; border-top: 1px dotted var(--border); }
|
||||
.admin-system h2 { font-size: 1.05em; margin: 0 0 8px; color: var(--muted); }
|
||||
.system-grid {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 6px 16px;
|
||||
margin: 0;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
.system-grid dt { font-weight: 600; color: var(--muted); }
|
||||
.system-grid dd { margin: 0; }
|
||||
.system-grid code { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 0.9em; color: var(--accent); }
|
||||
.system-grid .ok { color: var(--ok); font-weight: 500; }
|
||||
|
||||
.health-list { list-style: none; padding: 0; margin: 0; }
|
||||
.health-row { display: flex; gap: 8px; align-items: center; padding: 4px 0; }
|
||||
.health-row .dot {
|
||||
display: inline-block; width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--muted);
|
||||
}
|
||||
.health-ok .dot { background: var(--ok); }
|
||||
.health-down .dot { background: var(--bad); }
|
||||
.health-row a { color: var(--muted); text-decoration: none; }
|
||||
.health-row a:hover { color: var(--accent); }
|
||||
|
||||
51
web/templates/admin.tmpl
Normal file
51
web/templates/admin.tmpl
Normal file
@@ -0,0 +1,51 @@
|
||||
{{define "content"}}
|
||||
<h1>Admin</h1>
|
||||
<p class="muted">Tools for ongoing maintenance of projax data + integrations. The pages also live as direct routes (linked in each card).</p>
|
||||
|
||||
<section class="admin-cards">
|
||||
{{range .Cards}}
|
||||
<article class="admin-card {{if .Disabled}}disabled{{end}}">
|
||||
<header>
|
||||
<h2>
|
||||
{{if .Disabled}}{{.Title}}{{else}}<a href="{{.Href}}">{{.Title}}</a>{{end}}
|
||||
</h2>
|
||||
{{if not .Disabled}}
|
||||
<p class="count"><strong>{{.Count}}</strong> <small class="muted">{{.CountLabel}}</small></p>
|
||||
{{else}}
|
||||
<p class="count muted">— {{.DisabledMsg}}</p>
|
||||
{{end}}
|
||||
</header>
|
||||
<p class="desc">{{.Description}}</p>
|
||||
</article>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<section class="admin-system">
|
||||
<h2>System</h2>
|
||||
<dl class="system-grid">
|
||||
<dt>Version</dt>
|
||||
<dd>{{if .Panel.Version}}<code>{{.Panel.Version}}</code>{{else}}<span class="muted">unset (build without -ldflags)</span>{{end}}</dd>
|
||||
|
||||
<dt>Last migration</dt>
|
||||
<dd>{{if .Panel.MigrationLast}}<code>{{.Panel.MigrationLast}}</code>{{else}}<span class="muted">no migrations recorded</span>{{end}}</dd>
|
||||
|
||||
<dt>MCP</dt>
|
||||
<dd>{{if .Panel.MCPEnabled}}<span class="ok">enabled</span> · <code>/mcp/rpc</code>{{else}}<span class="muted">disabled</span> (PROJAX_MCP_TOKEN unset){{end}}</dd>
|
||||
|
||||
{{if .Panel.Healths}}
|
||||
<dt>Upstreams</dt>
|
||||
<dd>
|
||||
<ul class="health-list">
|
||||
{{range .Panel.Healths}}
|
||||
<li class="health-row {{if .OK}}health-ok{{else}}health-down{{end}}">
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
<strong>{{.Name}}</strong>
|
||||
<a href="{{.URL}}" target="_blank" rel="noopener noreferrer"><code>{{.URL}}</code></a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</dd>
|
||||
{{end}}
|
||||
</dl>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -31,9 +31,7 @@
|
||||
<a href="/" class="brand">projax</a>
|
||||
<a href="/dashboard">dashboard</a>
|
||||
<a href="/graph">graph</a>
|
||||
<a href="/admin/classify">classify orphans</a>
|
||||
<a href="/admin/bulk">bulk edit</a>
|
||||
<a href="/admin/caldav">caldav</a>
|
||||
<a href="/admin">admin</a>
|
||||
<form method="post" action="/logout" class="logout-form">
|
||||
<button type="submit" class="logout-btn">sign out</button>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user