mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-02 01:38:17 +00:00
Dashboard pass
This commit is contained in:
@@ -104,6 +104,47 @@ func (s *Store) ListCommandLogSessions(ctx context.Context, repeaterID int64, li
|
||||
return groups, rows.Err()
|
||||
}
|
||||
|
||||
// OwnerCommandLogEntry is a recent command across all repeaters a user owns,
|
||||
// joined with the repeater's name and the sender's name (for the dashboard).
|
||||
type OwnerCommandLogEntry struct {
|
||||
RepeaterID int64
|
||||
RepeaterName string
|
||||
SenderName string
|
||||
CommandText string
|
||||
SentAt time.Time
|
||||
AckReceived bool
|
||||
ResponseText *string
|
||||
}
|
||||
|
||||
// ListRecentCommandsForOwner returns the most recent commands run on any
|
||||
// repeater owned by ownerID, newest first, bounded by limit.
|
||||
func (s *Store) ListRecentCommandsForOwner(ctx context.Context, ownerID int64, limit int) ([]OwnerCommandLogEntry, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT l.repeater_id, r.name,
|
||||
COALESCE(NULLIF(u.display_name, ''), u.username, '(deleted)'),
|
||||
l.command_text, l.sent_at, l.ack_received, l.response_text
|
||||
FROM command_log l
|
||||
JOIN repeaters r ON r.id = l.repeater_id
|
||||
LEFT JOIN users u ON u.id = l.user_id
|
||||
WHERE r.owner_id = $1
|
||||
ORDER BY l.sent_at DESC
|
||||
LIMIT $2`, ownerID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list owner commands: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []OwnerCommandLogEntry
|
||||
for rows.Next() {
|
||||
var e OwnerCommandLogEntry
|
||||
if err := rows.Scan(&e.RepeaterID, &e.RepeaterName, &e.SenderName,
|
||||
&e.CommandText, &e.SentAt, &e.AckReceived, &e.ResponseText); err != nil {
|
||||
return nil, fmt.Errorf("scan owner command: %w", err)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListCommandLog returns recent log entries for a repeater, newest first.
|
||||
func (s *Store) ListCommandLog(ctx context.Context, repeaterID int64, limit int) ([]*CommandLogEntry, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
|
||||
+20
-11
@@ -107,23 +107,32 @@ func (s *Server) pageOrg(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "could not load repeaters", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
hasMap := false
|
||||
mapped := 0
|
||||
for _, rp := range repeaters {
|
||||
if rp.HasLocation {
|
||||
hasMap = true
|
||||
break
|
||||
mapped++
|
||||
}
|
||||
}
|
||||
adminCount := 0
|
||||
for _, m := range members {
|
||||
if m.Role == "admin" {
|
||||
adminCount++
|
||||
}
|
||||
}
|
||||
isAdmin := role == "admin"
|
||||
data := map[string]any{
|
||||
"Org": org,
|
||||
"Role": role,
|
||||
"IsAdmin": isAdmin,
|
||||
"Members": members,
|
||||
"Repeaters": repeaters,
|
||||
"HasMap": hasMap,
|
||||
"Self": uid,
|
||||
"Error": r.URL.Query().Get("error"),
|
||||
"Org": org,
|
||||
"Role": role,
|
||||
"IsAdmin": isAdmin,
|
||||
"Members": members,
|
||||
"Repeaters": repeaters,
|
||||
"HasMap": mapped > 0,
|
||||
"MemberCount": len(members),
|
||||
"RepeaterCount": len(repeaters),
|
||||
"AdminCount": adminCount,
|
||||
"MappedCount": mapped,
|
||||
"Self": uid,
|
||||
"Error": r.URL.Query().Get("error"),
|
||||
}
|
||||
if isAdmin {
|
||||
invites, err := s.store.ListOrgInvites(r.Context(), id)
|
||||
|
||||
@@ -41,10 +41,24 @@ code.pubkey {
|
||||
.ev-sent { color: var(--tblr-azure); }
|
||||
.ev-reply { color: var(--tblr-body-color); white-space: pre-wrap; }
|
||||
|
||||
/* ---------- Leaflet map ---------- */
|
||||
/* ---------- Leaflet map (dark) ---------- */
|
||||
#map {
|
||||
height: 360px;
|
||||
border-radius: var(--tblr-border-radius);
|
||||
border: 1px solid var(--tblr-border-color);
|
||||
}
|
||||
.leaflet-popup-content { color: #111; }
|
||||
.leaflet-container { background: #14171c; }
|
||||
.leaflet-popup-content-wrapper,
|
||||
.leaflet-popup-tip {
|
||||
background: var(--tblr-bg-surface, #1a1d24);
|
||||
color: var(--tblr-body-color, #e6edf3);
|
||||
box-shadow: var(--tblr-box-shadow, 0 1px 2px rgba(0, 0, 0, 0.4));
|
||||
}
|
||||
.leaflet-popup-content { color: var(--tblr-body-color, #e6edf3); }
|
||||
.leaflet-bar a,
|
||||
.leaflet-control-zoom a {
|
||||
background: var(--tblr-bg-surface, #1a1d24);
|
||||
color: var(--tblr-body-color, #e6edf3);
|
||||
border-bottom-color: var(--tblr-border-color, #2b2f36);
|
||||
}
|
||||
.leaflet-bar a:hover { background: var(--tblr-bg-surface-secondary, #22262e); }
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// meshMap renders a dark-mode Leaflet map of points into the element with the
|
||||
// given id. pts is an array of {name, lat, lon}. Does nothing if pts is empty.
|
||||
function meshMap(elId, pts) {
|
||||
if (!pts || !pts.length) return;
|
||||
var map = L.map(elId, { scrollWheelZoom: false });
|
||||
// CARTO "dark matter" basemap (OpenStreetMap data) for a dark UI.
|
||||
L.tileLayer("https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", {
|
||||
maxZoom: 19,
|
||||
subdomains: "abcd",
|
||||
attribution: "© OpenStreetMap © CARTO",
|
||||
}).addTo(map);
|
||||
var group = L.featureGroup(
|
||||
pts.map(function (p) {
|
||||
return L.circleMarker([p.lat, p.lon], {
|
||||
radius: 7,
|
||||
color: "#4dabf7",
|
||||
weight: 2,
|
||||
fillColor: "#4dabf7",
|
||||
fillOpacity: 0.6,
|
||||
}).bindPopup(p.name);
|
||||
})
|
||||
).addTo(map);
|
||||
map.fitBounds(group.getBounds().pad(0.3));
|
||||
}
|
||||
@@ -16,12 +16,10 @@
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-menu" aria-controls="navbar-menu" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="navbar-brand navbar-brand-autodark d-none-navbar-horizontal pe-0 pe-md-3">
|
||||
<a href="/" class="d-flex align-items-center text-reset text-decoration-none">
|
||||
{{template "icon-tower" "me-2"}}<span class="fw-bold">MeshTender</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="navbar-nav flex-row order-md-last">
|
||||
<a href="/" class="navbar-brand navbar-brand-autodark d-flex align-items-center text-reset text-decoration-none">
|
||||
{{template "icon-tower" "me-2"}}<span class="fw-bold fs-3">MeshTender</span>
|
||||
</a>
|
||||
<div class="navbar-nav flex-row order-md-last align-items-center gap-2">
|
||||
{{if .UserName}}
|
||||
<div class="nav-item dropdown">
|
||||
<a href="#" class="nav-link d-flex lh-1 p-0 px-2" data-bs-toggle="dropdown" aria-label="Open user menu">
|
||||
@@ -45,6 +43,12 @@
|
||||
{{if .UserName}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/">
|
||||
<span class="nav-link-icon d-md-none d-lg-inline-block">{{template "icon-home" ""}}</span>
|
||||
<span class="nav-link-title">Dashboard</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/repeaters">
|
||||
<span class="nav-link-icon d-md-none d-lg-inline-block">{{template "icon-antenna" ""}}</span>
|
||||
<span class="nav-link-title">Repeaters</span>
|
||||
</a>
|
||||
@@ -126,6 +130,42 @@
|
||||
</body>
|
||||
</html>{{end}}
|
||||
|
||||
{{/* landingbase is the public marketing layout: a transparent navbar with
|
||||
sign-in / sign-up actions, the page content, and a simple footer. Pages opt
|
||||
in via the "Layout":"landingbase" render key. */}}
|
||||
{{define "landingbase"}}<!doctype html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>{{template "title" .}}</title>
|
||||
<link rel="stylesheet" href="/static/tabler.min.css">
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<script src="/static/tabler.min.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="navbar navbar-expand-md navbar-transparent py-3">
|
||||
<div class="container">
|
||||
<a href="/" class="navbar-brand navbar-brand-autodark d-flex align-items-center text-reset text-decoration-none">
|
||||
{{template "icon-tower" "me-2"}}<span class="fw-bold fs-3">MeshTender</span>
|
||||
</a>
|
||||
<div class="navbar-nav flex-row order-md-last align-items-center gap-2">
|
||||
<a href="/login" class="btn btn-ghost-secondary">Sign in</a>
|
||||
<a href="/signup" class="btn btn-primary">Create account</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{{template "content" .}}
|
||||
<footer class="footer footer-transparent">
|
||||
<div class="container">
|
||||
<div class="row align-items-center justify-content-center">
|
||||
<div class="col-auto text-secondary">MeshTender — tend your mesh.</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>{{end}}
|
||||
|
||||
{{/* revokedoc explains how to revoke MeshTender's access. Dot is the revoke command string. */}}
|
||||
{{define "revokedoc"}}
|
||||
<details class="mt-3">
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
{{define "header"}}
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col">
|
||||
<div class="page-pretitle">Overview</div>
|
||||
<h2 class="page-title">Your repeaters</h2>
|
||||
<div class="page-pretitle">Dashboard</div>
|
||||
<h2 class="page-title">Welcome back{{if .UserName}}, {{.UserName}}{{end}}</h2>
|
||||
</div>
|
||||
<div class="col-auto ms-auto d-print-none">
|
||||
<a class="btn btn-primary" href="/repeaters/add">{{template "icon-plus" "me-1"}}Add repeater</a>
|
||||
@@ -12,77 +12,176 @@
|
||||
{{end}}
|
||||
{{define "content"}}
|
||||
{{if .Error}}<div class="alert alert-danger" role="alert">{{.Error}}</div>{{end}}
|
||||
{{if .Reconsent}}
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<div class="d-flex"><div>{{template "icon-alert" "alert-icon"}}</div>
|
||||
<div>One or more of your repeaters need re-consent after an organization changed its command policy.
|
||||
<a href="/repeaters" class="alert-link">Review on the Repeaters page</a>.</div></div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Owned}}
|
||||
<!-- Summary stats -->
|
||||
<div class="row row-cards">
|
||||
{{range .Owned}}
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card card-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center flex-wrap gap-2 mb-2">
|
||||
<h3 class="card-title mb-0 me-1">{{.Name}}</h3>
|
||||
{{template "repstatus" .}}
|
||||
{{if index $.Reconsent .ID}}<a class="badge bg-warning-lt" href="/repeaters/{{.ID}}/share">re-consent needed</a>{{end}}
|
||||
</div>
|
||||
<code class="pk">{{.PublicKeyHex}}</code>
|
||||
<div class="text-secondary small mt-2">{{.RadioFreqHz}} Hz · BW {{.RadioBwHz}} · SF{{.RadioSF}} · CR{{.RadioCR}}</div>
|
||||
{{if and .Confirmed .AccessKnown (not .IsAdmin)}}
|
||||
<div class="alert alert-warning mt-3 mb-0">
|
||||
MeshTender only has <strong>guest</strong> access here. Guest is available to anyone with a blank password, so there's no point operating at this level — re-run <code>setperm <your key> 3</code> on the repeater to grant admin, then re-test.
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="btn-list mt-3">
|
||||
<a class="btn btn-primary" href="/repeaters/{{.ID}}/console">{{template "icon-terminal" "me-1"}}Console</a>
|
||||
<div class="dropdown">
|
||||
<button class="btn dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">Manage</button>
|
||||
<div class="dropdown-menu dropdown-menu-end">
|
||||
<a class="dropdown-item" href="/repeaters/{{.ID}}/edit">{{template "icon-pencil" "dropdown-item-icon"}}Edit details</a>
|
||||
<a class="dropdown-item" href="/repeaters/{{.ID}}/confirm">{{template "icon-antenna" "dropdown-item-icon"}}Confirm access</a>
|
||||
<a class="dropdown-item" href="/repeaters/{{.ID}}/log">{{template "icon-list" "dropdown-item-icon"}}Logs</a>
|
||||
<a class="dropdown-item" href="/repeaters/{{.ID}}/share">{{template "icon-share" "dropdown-item-icon"}}Sharing</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<a class="dropdown-item text-danger" href="/repeaters/{{.ID}}/delete">{{template "icon-trash" "dropdown-item-icon"}}Delete repeater</a>
|
||||
</div>
|
||||
<div class="row align-items-center">
|
||||
<div class="col-auto"><span class="bg-primary text-white avatar">{{template "icon-antenna" ""}}</span></div>
|
||||
<div class="col">
|
||||
<div class="h1 mb-0">{{.OwnedCount}}</div>
|
||||
<div class="text-secondary">Repeater{{if ne .OwnedCount 1}}s{{end}} owned</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty">
|
||||
<div class="empty-icon">{{template "icon-antenna" ""}}</div>
|
||||
<p class="empty-title">No repeaters yet</p>
|
||||
<p class="empty-subtitle text-secondary">Add one to grant MeshTender access and test it with a modem.</p>
|
||||
<div class="empty-action">
|
||||
<a href="/repeaters/add" class="btn btn-primary">{{template "icon-plus" "me-1"}}Add repeater</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Shared}}
|
||||
<h3 class="mt-4 mb-3">Shared with you</h3>
|
||||
<div class="row row-cards">
|
||||
{{range .Shared}}
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card card-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center flex-wrap gap-2 mb-2">
|
||||
<h3 class="card-title mb-0 me-1">{{.Name}}</h3>
|
||||
{{template "repstatus" .}}
|
||||
<span class="badge bg-azure-lt">via {{.OwnerName}}</span>
|
||||
</div>
|
||||
<code class="pk">{{.PublicKeyHex}}</code>
|
||||
<div class="text-secondary small mt-2">{{.RadioFreqHz}} Hz · BW {{.RadioBwHz}} · SF{{.RadioSF}} · CR{{.RadioCR}}</div>
|
||||
<div class="btn-list mt-3">
|
||||
<a class="btn btn-primary" href="/repeaters/{{.ID}}/console">{{template "icon-terminal" "me-1"}}Console</a>
|
||||
<a class="btn" href="/repeaters/{{.ID}}/confirm">Confirm / test</a>
|
||||
<div class="row align-items-center">
|
||||
<div class="col-auto"><span class="bg-green text-white avatar">{{template "icon-check" ""}}</span></div>
|
||||
<div class="col">
|
||||
<div class="h1 mb-0">{{.Confirmed}}</div>
|
||||
<div class="text-secondary">Confirmed{{if .Unconfirmed}} · {{.Unconfirmed}} pending{{end}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card card-sm">
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-auto"><span class="bg-azure text-white avatar">{{template "icon-world" ""}}</span></div>
|
||||
<div class="col">
|
||||
<div class="h1 mb-0">{{.OrgCount}}</div>
|
||||
<div class="text-secondary">Organization{{if ne .OrgCount 1}}s{{end}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card card-sm">
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-auto"><span class="bg-purple text-white avatar">{{template "icon-share" ""}}</span></div>
|
||||
<div class="col">
|
||||
<div class="h1 mb-0">{{.SharedCount}}</div>
|
||||
<div class="text-secondary">Shared with you</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row row-cards mt-1">
|
||||
<!-- Left column -->
|
||||
<div class="col-lg-8">
|
||||
<!-- Your repeaters -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Your repeaters</h3>
|
||||
<div class="card-actions"><a href="/repeaters" class="btn btn-sm">View all{{if .OwnedCount}} ({{.OwnedCount}}){{end}}</a></div>
|
||||
</div>
|
||||
{{if .Owned}}
|
||||
<div class="list-group list-group-flush">
|
||||
{{range .Owned}}
|
||||
<div class="list-group-item d-flex align-items-center gap-2">
|
||||
<span class="text-secondary">{{template "icon-antenna" ""}}</span>
|
||||
<div class="flex-fill text-truncate">
|
||||
<a class="fw-bold text-reset text-decoration-none" href="/repeaters/{{.ID}}/console">{{.Name}}</a>
|
||||
<div class="text-secondary small text-truncate">{{.RadioFreqHz}} Hz · SF{{.RadioSF}}</div>
|
||||
</div>
|
||||
{{template "repstatus" .}}
|
||||
<a class="btn btn-sm" href="/repeaters/{{.ID}}/console">{{template "icon-terminal" "me-1"}}Console</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="card-body">
|
||||
<div class="empty">
|
||||
<div class="empty-icon">{{template "icon-antenna" ""}}</div>
|
||||
<p class="empty-title">No repeaters yet</p>
|
||||
<p class="empty-subtitle text-secondary">Add one to grant MeshTender access and test it with a modem.</p>
|
||||
<div class="empty-action"><a href="/repeaters/add" class="btn btn-primary">{{template "icon-plus" "me-1"}}Add repeater</a></div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- Recent activity -->
|
||||
<div class="card mt-3">
|
||||
<div class="card-header"><h3 class="card-title">Recent activity</h3></div>
|
||||
{{if .Recent}}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table">
|
||||
<thead><tr><th>Repeater</th><th>Command</th><th>By</th><th>When</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Recent}}
|
||||
<tr>
|
||||
<td class="text-truncate" style="max-width:10rem"><a class="text-reset" href="/repeaters/{{.RepeaterID}}/log">{{.RepeaterName}}</a></td>
|
||||
<td><code>{{.CommandText}}</code></td>
|
||||
<td class="text-secondary">{{.SenderName}}</td>
|
||||
<td class="text-secondary text-nowrap">{{.SentAt.Format "Jan 2, 15:04"}}</td>
|
||||
<td>{{if .AckReceived}}<span class="badge bg-success-lt">reply</span>{{else}}<span class="badge bg-yellow-lt">no reply</span>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="card-body"><p class="text-secondary mb-0">No commands have been run on your repeaters yet. Open a repeater's console to send one.</p></div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right column -->
|
||||
<div class="col-lg-4">
|
||||
<!-- Map -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h3 class="card-title">Repeater map</h3></div>
|
||||
{{if .Mapped}}
|
||||
<div class="card-body p-2">
|
||||
<link rel="stylesheet" href="/static/leaflet.css">
|
||||
<div id="map" style="height:260px"></div>
|
||||
<script src="/static/leaflet.js"></script>
|
||||
<script src="/static/meshmap.js"></script>
|
||||
<script>
|
||||
meshMap('map', [
|
||||
{{range .Mapped}}{name: {{.Name}}, lat: {{.Latitude}}, lon: {{.Longitude}}},{{end}}
|
||||
]);
|
||||
</script>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="card-body"><p class="text-secondary mb-0">No repeater locations yet. Enable “store location” when editing a repeater and confirm it with a modem to place it on the map.</p></div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- Organizations -->
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Organizations</h3>
|
||||
<div class="card-actions"><a href="/orgs" class="btn btn-sm">View all{{if .OrgCount}} ({{.OrgCount}}){{end}}</a></div>
|
||||
</div>
|
||||
{{if .Orgs}}
|
||||
<div class="list-group list-group-flush">
|
||||
{{range .Orgs}}
|
||||
<a href="/orgs/{{.Org.ID}}" class="list-group-item list-group-item-action d-flex align-items-center gap-2">
|
||||
<span class="text-secondary">{{template "icon-world" ""}}</span>
|
||||
<span class="flex-fill text-truncate fw-bold">{{.Org.Name}}</span>
|
||||
{{if eq .Role "admin"}}<span class="badge bg-success-lt">admin</span>{{else}}<span class="badge bg-azure-lt">member</span>{{end}}
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="card-body">
|
||||
<p class="text-secondary">You're not in any organizations yet. Join one to help tend a mesh together.</p>
|
||||
<a href="/orgs" class="btn btn-sm">Browse organizations</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
{{define "title"}}MeshTender · Tend your mesh{{end}}
|
||||
{{define "content"}}
|
||||
<!-- Hero -->
|
||||
<div class="page-header">
|
||||
<div class="container">
|
||||
<div class="row align-items-center py-5">
|
||||
<div class="col-lg-7">
|
||||
<h1 class="display-5 fw-bold mb-3">Tend your MeshCore network — without handing out keys.</h1>
|
||||
<p class="fs-3 text-secondary mb-4">
|
||||
MeshTender manages your LoRa repeaters through a single trusted identity. Grant access once,
|
||||
then share, organize, and audit who can do what — and revoke any time.
|
||||
</p>
|
||||
<div class="btn-list">
|
||||
<a href="/signup" class="btn btn-primary btn-lg">{{template "icon-key" "me-2"}}Get started</a>
|
||||
<a href="/orgs" class="btn btn-lg">Browse organizations</a>
|
||||
</div>
|
||||
<p class="text-secondary mt-3 mb-0">No email required. Sign in with a passkey.</p>
|
||||
</div>
|
||||
<div class="col-lg-5 d-none d-lg-flex justify-content-center">
|
||||
<span class="text-primary" style="opacity:.85">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="220" height="220" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><path d="M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/><path d="M16.616 13.924a5 5 0 1 0 -9.23 0"/><path d="M20.307 15.469a9 9 0 1 0 -16.615 0"/><path d="M9 21l3 -9l3 9"/><path d="M10 19h4"/></svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
<div class="page-body">
|
||||
<div class="container">
|
||||
<div class="row row-cards">
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<span class="avatar bg-primary-lt text-primary me-3">{{template "icon-key" ""}}</span>
|
||||
<h3 class="card-title m-0">One identity, no key sharing</h3>
|
||||
</div>
|
||||
<p class="text-secondary mb-0">Grant MeshTender admin on a repeater once. Everyone you authorize operates through that single identity — you never distribute private keys.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<span class="avatar bg-primary-lt text-primary me-3">{{template "icon-share" ""}}</span>
|
||||
<h3 class="card-title m-0">Share with precision</h3>
|
||||
</div>
|
||||
<p class="text-secondary mb-0">Hand specific people single-use links, and approve the exact commands each may run. Contribute repeaters to organizations with command policies you review first.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<span class="avatar bg-primary-lt text-primary me-3">{{template "icon-antenna" ""}}</span>
|
||||
<h3 class="card-title m-0">Confirm reachability</h3>
|
||||
</div>
|
||||
<p class="text-secondary mb-0">Connect a KISS modem in your browser and verify a repeater is reachable. Others corroborate it from their own modems — proof it's truly on the air.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<span class="avatar bg-primary-lt text-primary me-3">{{template "icon-list" ""}}</span>
|
||||
<h3 class="card-title m-0">Full audit log</h3>
|
||||
</div>
|
||||
<p class="text-secondary mb-0">Every command sent through MeshTender is logged by session, with who sent it and whether the repeater replied.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<span class="avatar bg-primary-lt text-primary me-3">{{template "icon-world" ""}}</span>
|
||||
<h3 class="card-title m-0">Map your network</h3>
|
||||
</div>
|
||||
<p class="text-secondary mb-0">Opt repeaters into a map so your organization — or the public — can see where coverage reaches.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<span class="avatar bg-primary-lt text-primary me-3">{{template "icon-user" ""}}</span>
|
||||
<h3 class="card-title m-0">Passwordless by default</h3>
|
||||
</div>
|
||||
<p class="text-secondary mb-0">Create an account with just a username and a passkey. No email, no password to leak — with a password as an optional fallback.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CTA -->
|
||||
<div class="card card-md mt-4">
|
||||
<div class="card-body text-center py-5">
|
||||
<h2 class="mb-3">Ready to tend your mesh?</h2>
|
||||
<p class="text-secondary mb-4">Register a repeater in minutes and bring your team along.</p>
|
||||
<div class="btn-list justify-content-center">
|
||||
<a href="/signup" class="btn btn-primary btn-lg">Create your account</a>
|
||||
<a href="/login" class="btn btn-lg">Sign in</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
+162
-111
@@ -2,142 +2,193 @@
|
||||
{{define "header"}}
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col">
|
||||
<div class="page-pretitle">Organization</div>
|
||||
<div class="page-pretitle">Organization · you're {{if .IsAdmin}}an admin{{else}}a member{{end}}</div>
|
||||
<h2 class="page-title">{{.Org.Name}}</h2>
|
||||
</div>
|
||||
<div class="col-auto ms-auto d-print-none">
|
||||
<div class="btn-list">
|
||||
{{if .IsAdmin}}<a class="btn" href="/orgs/{{.Org.ID}}/permissions">{{template "icon-settings" "me-1"}}Edit permissions</a>{{end}}
|
||||
<a class="btn" href="/orgs/{{.Org.ID}}?view=public">{{template "icon-world" "me-1"}}Public page</a>
|
||||
<form method="post" action="/orgs/{{.Org.ID}}/leave" class="m-0" onsubmit="return confirm('Leave this organization?')">
|
||||
<button type="submit" class="btn btn-ghost-danger">Leave</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{define "content"}}
|
||||
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<p class="text-secondary">You are {{if .IsAdmin}}an <strong>admin</strong>{{else}}a <strong>member</strong>{{end}} of this organization.</p>
|
||||
{{if .Org.Description}}<p>{{.Org.Description}}</p>{{end}}
|
||||
<div class="btn-list">
|
||||
{{if .IsAdmin}}<a class="btn" href="/orgs/{{.Org.ID}}/permissions">{{template "icon-settings" "me-1"}}Edit permissions</a>{{end}}
|
||||
<a class="btn" href="/orgs/{{.Org.ID}}?view=public">{{template "icon-world" "me-1"}}View public page</a>
|
||||
<form method="post" action="/orgs/{{.Org.ID}}/leave" class="m-0" onsubmit="return confirm('Leave this organization?')">
|
||||
<button type="submit" class="btn btn-ghost-danger">Leave organization</button>
|
||||
</form>
|
||||
<!-- Summary stats -->
|
||||
<div class="row row-cards">
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card card-sm">
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-auto"><span class="bg-primary text-white avatar">{{template "icon-users" ""}}</span></div>
|
||||
<div class="col"><div class="h1 mb-0">{{.MemberCount}}</div><div class="text-secondary">Member{{if ne .MemberCount 1}}s{{end}}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card card-sm">
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-auto"><span class="bg-azure text-white avatar">{{template "icon-antenna" ""}}</span></div>
|
||||
<div class="col"><div class="h1 mb-0">{{.RepeaterCount}}</div><div class="text-secondary">Repeater{{if ne .RepeaterCount 1}}s{{end}}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card card-sm">
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-auto"><span class="bg-green text-white avatar">{{template "icon-user" ""}}</span></div>
|
||||
<div class="col"><div class="h1 mb-0">{{.AdminCount}}</div><div class="text-secondary">Admin{{if ne .AdminCount 1}}s{{end}}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card card-sm">
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-auto"><span class="bg-purple text-white avatar">{{template "icon-world" ""}}</span></div>
|
||||
<div class="col"><div class="h1 mb-0">{{.MappedCount}}</div><div class="text-secondary">On the map</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .IsAdmin}}
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title">Organization profile <span class="text-secondary">(public)</span></h3>
|
||||
<p class="text-secondary">Name and description are shown on the public organization page and directory.</p>
|
||||
<form method="post" action="/orgs/{{.Org.ID}}/edit">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" class="form-control" name="name" value="{{.Org.Name}}" maxlength="80" required>
|
||||
<div class="row row-cards mt-1">
|
||||
<!-- Left column -->
|
||||
<div class="col-lg-8">
|
||||
{{if .HasMap}}
|
||||
<div class="card">
|
||||
<div class="card-header"><h3 class="card-title">Repeater map</h3></div>
|
||||
<div class="card-body p-2">
|
||||
<link rel="stylesheet" href="/static/leaflet.css">
|
||||
<div id="map" style="height:300px"></div>
|
||||
<script src="/static/leaflet.js"></script>
|
||||
<script src="/static/meshmap.js"></script>
|
||||
<script>
|
||||
meshMap('map', [
|
||||
{{range .Repeaters}}{{if .HasLocation}}{name: {{.Name}}, lat: {{.Lat}}, lon: {{.Lon}}},{{end}}{{end}}
|
||||
]);
|
||||
</script>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<input type="text" class="form-control" name="description" value="{{.Org.Description}}" maxlength="2000" placeholder="What this organization is about, where it operates, etc.">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Save profile</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title">Members</h3>
|
||||
<div class="list-group list-group-flush">
|
||||
{{range .Members}}
|
||||
<div class="list-group-item d-flex align-items-center flex-wrap gap-2 px-0">
|
||||
<span class="fw-bold">{{.Name}}</span>
|
||||
<span class="text-secondary">@{{.Username}}</span>
|
||||
{{if eq .Role "admin"}}<span class="badge bg-success-lt">admin</span>{{else}}<span class="badge bg-azure-lt">member</span>{{end}}
|
||||
{{if and $.IsAdmin (ne .UserID $.Self)}}
|
||||
<form method="post" action="/orgs/{{$.Org.ID}}/members/{{.UserID}}" class="m-0 ms-auto d-flex gap-2">
|
||||
{{if eq .Role "admin"}}
|
||||
<button type="submit" name="action" value="demote" class="btn btn-sm">Demote</button>
|
||||
{{else}}
|
||||
<button type="submit" name="action" value="promote" class="btn btn-sm">Make admin</button>
|
||||
{{end}}
|
||||
<button type="submit" name="action" value="remove" class="btn btn-sm btn-ghost-danger">Remove</button>
|
||||
</form>
|
||||
<div class="card {{if .HasMap}}mt-3{{end}}">
|
||||
<div class="card-header"><h3 class="card-title">Repeaters</h3></div>
|
||||
{{if .Repeaters}}
|
||||
<div class="list-group list-group-flush">
|
||||
{{range .Repeaters}}
|
||||
<div class="list-group-item d-flex align-items-center gap-2">
|
||||
<span class="text-secondary">{{template "icon-antenna" ""}}</span>
|
||||
<div class="flex-fill text-truncate">
|
||||
<span class="fw-bold">{{.Name}}</span>
|
||||
<div class="text-secondary small">owner {{.OwnerName}}</div>
|
||||
</div>
|
||||
<a class="btn btn-primary btn-sm" href="/repeaters/{{.RepeaterID}}/console">{{template "icon-terminal" "me-1"}}Console</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="card-body"><p class="text-secondary mb-0">No repeaters have been contributed yet. Members can contribute their own from a repeater's “Organizations” page.</p></div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title">Repeaters</h3>
|
||||
{{if .HasMap}}
|
||||
<link rel="stylesheet" href="/static/leaflet.css">
|
||||
<div id="map" class="mb-3"></div>
|
||||
<script src="/static/leaflet.js"></script>
|
||||
<script>
|
||||
var pts = [
|
||||
{{range .Repeaters}}{{if .HasLocation}}{name: {{.Name}}, lat: {{.Lat}}, lon: {{.Lon}}},{{end}}{{end}}
|
||||
];
|
||||
var map = L.map('map');
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19, attribution: '© OpenStreetMap'
|
||||
}).addTo(map);
|
||||
var group = L.featureGroup(pts.map(function (p) {
|
||||
return L.circleMarker([p.lat, p.lon], { radius: 7, color: '#066fd1', fillOpacity: 0.7 }).bindPopup(p.name);
|
||||
})).addTo(map);
|
||||
map.fitBounds(group.getBounds().pad(0.2));
|
||||
</script>
|
||||
{{end}}
|
||||
{{if .Repeaters}}
|
||||
<div class="list-group list-group-flush">
|
||||
{{range .Repeaters}}
|
||||
<div class="list-group-item d-flex align-items-center gap-2 px-0">
|
||||
<span class="fw-bold">{{.Name}}</span>
|
||||
<span class="text-secondary">owner {{.OwnerName}}</span>
|
||||
<a class="btn btn-primary btn-sm ms-auto" href="/repeaters/{{.RepeaterID}}/console">{{template "icon-terminal" "me-1"}}Console</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-secondary mb-0">No repeaters have been contributed yet. Members can contribute their own from a repeater's “Organizations” page.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .IsAdmin}}
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title">Join links</h3>
|
||||
<p class="text-secondary">Anyone with a link joins as a member. Promote them to admin above if needed.</p>
|
||||
{{if .Invites}}
|
||||
<div class="list-group list-group-flush mb-3">
|
||||
{{range .Invites}}
|
||||
<div class="list-group-item px-0">
|
||||
<div class="fw-bold mb-1">{{if .Description}}{{.Description}}{{else}}(no label){{end}}</div>
|
||||
<div class="input-group mb-2">
|
||||
<code class="form-control font-monospace" id="ol-{{.ID}}">{{.Link}}</code>
|
||||
<button type="button" class="btn" onclick="navigator.clipboard.writeText(document.getElementById('ol-{{.ID}}').textContent)">{{template "icon-copy" ""}}Copy</button>
|
||||
<div class="card mt-3">
|
||||
<div class="card-header"><h3 class="card-title">Members</h3></div>
|
||||
<div class="list-group list-group-flush">
|
||||
{{range .Members}}
|
||||
<div class="list-group-item d-flex align-items-center flex-wrap gap-2">
|
||||
<div class="flex-fill text-truncate">
|
||||
<span class="fw-bold">{{.Name}}</span>
|
||||
<span class="text-secondary">@{{.Username}}</span>
|
||||
</div>
|
||||
{{if eq .Role "admin"}}<span class="badge bg-success-lt">admin</span>{{else}}<span class="badge bg-azure-lt">member</span>{{end}}
|
||||
{{if and $.IsAdmin (ne .UserID $.Self)}}
|
||||
<form method="post" action="/orgs/{{$.Org.ID}}/members/{{.UserID}}" class="m-0 d-flex gap-2">
|
||||
{{if eq .Role "admin"}}
|
||||
<button type="submit" name="action" value="demote" class="btn btn-sm">Demote</button>
|
||||
{{else}}
|
||||
<button type="submit" name="action" value="promote" class="btn btn-sm">Make admin</button>
|
||||
{{end}}
|
||||
<button type="submit" name="action" value="remove" class="btn btn-sm btn-ghost-danger">Remove</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
<form method="post" action="/orgs/{{$.Org.ID}}/invite/delete" class="m-0">
|
||||
<input type="hidden" name="invite_id" value="{{.ID}}">
|
||||
<button type="submit" class="btn btn-sm btn-ghost-danger">Revoke</button>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right column -->
|
||||
<div class="col-lg-4">
|
||||
{{if .Org.Description}}
|
||||
<div class="card">
|
||||
<div class="card-header"><h3 class="card-title">About</h3></div>
|
||||
<div class="card-body"><p class="mb-0">{{.Org.Description}}</p></div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .IsAdmin}}
|
||||
<div class="card {{if .Org.Description}}mt-3{{end}}">
|
||||
<div class="card-header"><h3 class="card-title">Organization profile <span class="text-secondary">(public)</span></h3></div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="/orgs/{{.Org.ID}}/edit">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" class="form-control" name="name" value="{{.Org.Name}}" maxlength="80" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-control" name="description" rows="3" maxlength="2000" placeholder="What this organization is about, where it operates, etc.">{{.Org.Description}}</textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Save profile</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header"><h3 class="card-title">Join links</h3></div>
|
||||
<div class="card-body">
|
||||
<p class="text-secondary">Anyone with a link joins as a member. Promote them to admin above if needed.</p>
|
||||
{{if .Invites}}
|
||||
<div class="list-group list-group-flush mb-3">
|
||||
{{range .Invites}}
|
||||
<div class="list-group-item px-0">
|
||||
<div class="fw-bold mb-1">{{if .Description}}{{.Description}}{{else}}(no label){{end}}</div>
|
||||
<div class="input-group mb-2">
|
||||
<code class="form-control font-monospace" id="ol-{{.ID}}">{{.Link}}</code>
|
||||
<button type="button" class="btn" onclick="navigator.clipboard.writeText(document.getElementById('ol-{{.ID}}').textContent)">{{template "icon-copy" ""}}Copy</button>
|
||||
</div>
|
||||
<form method="post" action="/orgs/{{$.Org.ID}}/invite/delete" class="m-0">
|
||||
<input type="hidden" name="invite_id" value="{{.ID}}">
|
||||
<button type="submit" class="btn btn-sm btn-ghost-danger">Revoke</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
<form method="post" action="/orgs/{{.Org.ID}}/invite">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">New link description <span class="text-secondary">(optional)</span></label>
|
||||
<input type="text" class="form-control" name="description" maxlength="100" placeholder="e.g. forum post">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Create join link</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
<form method="post" action="/orgs/{{.Org.ID}}/invite">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">New link description <span class="text-secondary">(optional)</span></label>
|
||||
<input type="text" class="form-control" name="description" maxlength="100" placeholder="e.g. forum post">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Create join link</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<a class="btn btn-link px-0 mt-3" href="/orgs">{{template "icon-arrow-left" "me-1"}}All organizations</a>
|
||||
{{end}}
|
||||
|
||||
@@ -35,18 +35,11 @@
|
||||
<link rel="stylesheet" href="/static/leaflet.css">
|
||||
<div id="map"></div>
|
||||
<script src="/static/leaflet.js"></script>
|
||||
<script src="/static/meshmap.js"></script>
|
||||
<script>
|
||||
var pts = [
|
||||
meshMap('map', [
|
||||
{{range .Repeaters}}{{if .HasLocation}}{name: {{.Name}}, lat: {{.Lat}}, lon: {{.Lon}}},{{end}}{{end}}
|
||||
];
|
||||
var map = L.map('map');
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19, attribution: '© OpenStreetMap'
|
||||
}).addTo(map);
|
||||
var group = L.featureGroup(pts.map(function (p) {
|
||||
return L.circleMarker([p.lat, p.lon], { radius: 7, color: '#066fd1', fillOpacity: 0.7 }).bindPopup(p.name);
|
||||
})).addTo(map);
|
||||
map.fitBounds(group.getBounds().pad(0.2));
|
||||
]);
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
{{define "title"}}Repeaters · MeshTender{{end}}
|
||||
{{define "header"}}
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col">
|
||||
<div class="page-pretitle">Your equipment</div>
|
||||
<h2 class="page-title">Repeaters</h2>
|
||||
</div>
|
||||
<div class="col-auto ms-auto d-print-none">
|
||||
<a class="btn btn-primary" href="/repeaters/add">{{template "icon-plus" "me-1"}}Add repeater</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{define "content"}}
|
||||
{{if .Error}}<div class="alert alert-danger" role="alert">{{.Error}}</div>{{end}}
|
||||
|
||||
{{if .Owned}}
|
||||
<div class="row row-cards">
|
||||
{{range .Owned}}
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center flex-wrap gap-2 mb-2">
|
||||
<h3 class="card-title mb-0 me-1">{{.Name}}</h3>
|
||||
{{template "repstatus" .}}
|
||||
{{if index $.Reconsent .ID}}<a class="badge bg-warning-lt" href="/repeaters/{{.ID}}/share">re-consent needed</a>{{end}}
|
||||
</div>
|
||||
<code class="pk">{{.PublicKeyHex}}</code>
|
||||
<div class="text-secondary small mt-2">{{.RadioFreqHz}} Hz · BW {{.RadioBwHz}} · SF{{.RadioSF}} · CR{{.RadioCR}}</div>
|
||||
{{if and .Confirmed .AccessKnown (not .IsAdmin)}}
|
||||
<div class="alert alert-warning mt-3 mb-0">
|
||||
MeshTender only has <strong>guest</strong> access here. Guest is available to anyone with a blank password, so there's no point operating at this level — re-run <code>setperm <your key> 3</code> on the repeater to grant admin, then re-test.
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="btn-list mt-3">
|
||||
<a class="btn btn-primary" href="/repeaters/{{.ID}}/console">{{template "icon-terminal" "me-1"}}Console</a>
|
||||
<div class="dropdown">
|
||||
<button class="btn dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">Manage</button>
|
||||
<div class="dropdown-menu dropdown-menu-end">
|
||||
<a class="dropdown-item" href="/repeaters/{{.ID}}/edit">{{template "icon-pencil" "dropdown-item-icon"}}Edit details</a>
|
||||
<a class="dropdown-item" href="/repeaters/{{.ID}}/confirm">{{template "icon-antenna" "dropdown-item-icon"}}Confirm access</a>
|
||||
<a class="dropdown-item" href="/repeaters/{{.ID}}/log">{{template "icon-list" "dropdown-item-icon"}}Logs</a>
|
||||
<a class="dropdown-item" href="/repeaters/{{.ID}}/share">{{template "icon-share" "dropdown-item-icon"}}Sharing</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<a class="dropdown-item text-danger" href="/repeaters/{{.ID}}/delete">{{template "icon-trash" "dropdown-item-icon"}}Delete repeater</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty">
|
||||
<div class="empty-icon">{{template "icon-antenna" ""}}</div>
|
||||
<p class="empty-title">No repeaters yet</p>
|
||||
<p class="empty-subtitle text-secondary">Add one to grant MeshTender access and test it with a modem.</p>
|
||||
<div class="empty-action">
|
||||
<a href="/repeaters/add" class="btn btn-primary">{{template "icon-plus" "me-1"}}Add repeater</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Shared}}
|
||||
<h3 class="mt-4 mb-3">Shared with you</h3>
|
||||
<div class="row row-cards">
|
||||
{{range .Shared}}
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center flex-wrap gap-2 mb-2">
|
||||
<h3 class="card-title mb-0 me-1">{{.Name}}</h3>
|
||||
{{template "repstatus" .}}
|
||||
<span class="badge bg-azure-lt">via {{.OwnerName}}</span>
|
||||
</div>
|
||||
<code class="pk">{{.PublicKeyHex}}</code>
|
||||
<div class="text-secondary small mt-2">{{.RadioFreqHz}} Hz · BW {{.RadioBwHz}} · SF{{.RadioSF}} · CR{{.RadioCR}}</div>
|
||||
<div class="btn-list mt-3">
|
||||
<a class="btn btn-primary" href="/repeaters/{{.ID}}/console">{{template "icon-terminal" "me-1"}}Console</a>
|
||||
<a class="btn" href="/repeaters/{{.ID}}/confirm">Confirm / test</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
+102
-10
@@ -63,6 +63,9 @@ func (s *Server) routes() {
|
||||
staticSub, _ := fs.Sub(staticFS, "static")
|
||||
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
|
||||
|
||||
// Root: marketing landing for visitors, overview dashboard for members.
|
||||
r.Get("/", s.pageHome)
|
||||
|
||||
// Public auth pages + JSON ceremony endpoints.
|
||||
r.Get("/login", s.pageLogin)
|
||||
r.Get("/signup", s.pageSignup)
|
||||
@@ -82,7 +85,7 @@ func (s *Server) routes() {
|
||||
// Authenticated area.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.auth.RequireUser)
|
||||
r.Get("/", s.pageDashboard)
|
||||
r.Get("/repeaters", s.pageRepeaters)
|
||||
r.Post("/logout", s.handleLogout)
|
||||
r.Get("/account", s.pageAccount)
|
||||
r.Post("/account/profile", s.handleUpdateProfile)
|
||||
@@ -194,7 +197,19 @@ func (s *Server) pageSignup(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) pageDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
// pageHome serves the marketing landing page to visitors and the overview
|
||||
// dashboard to signed-in members.
|
||||
func (s *Server) pageHome(w http.ResponseWriter, r *http.Request) {
|
||||
if s.auth.CurrentUserID(r.Context()) == 0 {
|
||||
s.render(w, r, "landing.html", map[string]any{"Layout": "landingbase"})
|
||||
return
|
||||
}
|
||||
s.pageDashboard(w, r)
|
||||
}
|
||||
|
||||
// pageRepeaters lists every repeater the user owns or has been shared, split
|
||||
// into owned and shared sections.
|
||||
func (s *Server) pageRepeaters(w http.ResponseWriter, r *http.Request) {
|
||||
uid := s.auth.CurrentUserID(r.Context())
|
||||
repeaters, err := s.store.ListRepeatersForUser(r.Context(), uid)
|
||||
if err != nil {
|
||||
@@ -206,8 +221,76 @@ func (s *Server) pageDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "could not load org state", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Split owned vs directly-shared for the two dashboard sections.
|
||||
var owned, shared []*store.Repeater
|
||||
owned, shared := splitOwnedShared(repeaters)
|
||||
s.render(w, r, "repeaters.html", map[string]any{
|
||||
"Owned": owned,
|
||||
"Shared": shared,
|
||||
"Reconsent": reconsent,
|
||||
"Error": r.URL.Query().Get("error"),
|
||||
})
|
||||
}
|
||||
|
||||
// pageDashboard renders the signed-in overview: summary stats, short lists of
|
||||
// repeaters and organizations, a map of owned repeaters, and recent activity.
|
||||
func (s *Server) pageDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
uid := s.auth.CurrentUserID(ctx)
|
||||
|
||||
repeaters, err := s.store.ListRepeatersForUser(ctx, uid)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load repeaters", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
owned, shared := splitOwnedShared(repeaters)
|
||||
|
||||
reconsent, err := s.store.OwnedRepeatersNeedingReconsent(ctx, uid)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load org state", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
orgs, err := s.store.ListOrgsForUser(ctx, uid)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load organizations", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
recent, err := s.store.ListRecentCommandsForOwner(ctx, uid, 8)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load activity", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Summary counts and the owned repeaters that have a stored location.
|
||||
confirmed, unconfirmed := 0, 0
|
||||
var mapped []*store.Repeater
|
||||
for _, rp := range owned {
|
||||
if rp.Confirmed {
|
||||
confirmed++
|
||||
} else {
|
||||
unconfirmed++
|
||||
}
|
||||
if rp.Latitude != nil && rp.Longitude != nil {
|
||||
mapped = append(mapped, rp)
|
||||
}
|
||||
}
|
||||
|
||||
s.render(w, r, "dashboard.html", map[string]any{
|
||||
"OwnedCount": len(owned),
|
||||
"SharedCount": len(shared),
|
||||
"OrgCount": len(orgs),
|
||||
"Confirmed": confirmed,
|
||||
"Unconfirmed": unconfirmed,
|
||||
"Owned": firstRepeaters(owned, 5),
|
||||
"Shared": firstRepeaters(shared, 5),
|
||||
"Orgs": firstOrgs(orgs, 5),
|
||||
"Mapped": mapped,
|
||||
"Recent": recent,
|
||||
"Reconsent": reconsent,
|
||||
"Error": r.URL.Query().Get("error"),
|
||||
})
|
||||
}
|
||||
|
||||
// splitOwnedShared partitions a repeater list into owned and directly-shared.
|
||||
func splitOwnedShared(repeaters []*store.Repeater) (owned, shared []*store.Repeater) {
|
||||
for _, rp := range repeaters {
|
||||
if rp.Shared {
|
||||
shared = append(shared, rp)
|
||||
@@ -215,12 +298,21 @@ func (s *Server) pageDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
owned = append(owned, rp)
|
||||
}
|
||||
}
|
||||
s.render(w, r, "dashboard.html", map[string]any{
|
||||
"Owned": owned,
|
||||
"Shared": shared,
|
||||
"Reconsent": reconsent,
|
||||
"Error": r.URL.Query().Get("error"),
|
||||
})
|
||||
return owned, shared
|
||||
}
|
||||
|
||||
func firstRepeaters(rs []*store.Repeater, n int) []*store.Repeater {
|
||||
if len(rs) > n {
|
||||
return rs[:n]
|
||||
}
|
||||
return rs
|
||||
}
|
||||
|
||||
func firstOrgs(os []store.OrgMembership, n int) []store.OrgMembership {
|
||||
if len(os) > n {
|
||||
return os[:n]
|
||||
}
|
||||
return os
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
Reference in New Issue
Block a user