Improve admin UI and remove unused coordinate tag type

- Replace node type badge with icon in admin tag editor
- Add Edit/Add Tags button on node detail page (when admin enabled and authenticated)
- Remove automatic seed container startup to prevent overwriting user changes
- Remove unused 'coordinate' value type from node tags (only string, number, boolean remain)
This commit is contained in:
Louis King
2026-01-11 12:49:34 +00:00
parent bec736a894
commit ed2cf09ff3
13 changed files with 56 additions and 43 deletions

View File

@@ -487,7 +487,7 @@ The database can be seeded with node tags and network members from YAML files in
- `node_tags.yaml` - Node tag definitions (keyed by public_key)
- `members.yaml` - Network member definitions
Seeding is a separate process from the collector and must be run explicitly:
**Important:** Seeding is NOT automatic and must be run explicitly. This prevents seed files from overwriting user changes made via the admin UI.
```bash
# Native CLI
@@ -497,6 +497,8 @@ meshcore-hub collector seed
docker compose --profile seed up
```
**Note:** Once the admin UI is enabled (`WEB_ADMIN_ENABLED=true`), tags should be managed through the web interface rather than seed files.
### Webhook Configuration
The collector supports forwarding events to external HTTP endpoints:

View File

@@ -471,21 +471,18 @@ Tags are keyed by public key in YAML format:
fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210:
friendly_name: Oakland Repeater
altitude: 150
location:
value: "37.8044,-122.2712"
type: coordinate
```
Tag values can be:
- **YAML primitives** (auto-detected type): strings, numbers, booleans
- **Explicit type** (for special types like coordinate):
- **Explicit type** (when you need to force a specific type):
```yaml
location:
value: "37.7749,-122.4194"
type: coordinate
altitude:
value: "150"
type: number
```
Supported types: `string`, `number`, `boolean`, `coordinate`
Supported types: `string`, `number`, `boolean`
### Import Tags Manually

View File

@@ -139,7 +139,7 @@ services:
- core
restart: unless-stopped
depends_on:
seed:
db-migrate:
condition: service_completed_successfully
volumes:
- ${DATA_HOME:-./data}:/data
@@ -196,7 +196,7 @@ services:
- core
restart: unless-stopped
depends_on:
seed:
db-migrate:
condition: service_completed_successfully
collector:
condition: service_started
@@ -295,7 +295,10 @@ services:
command: ["db", "upgrade"]
# ==========================================================================
# Seed Data - Import node_tags.json and members.json from SEED_HOME
# Seed Data - Import node_tags.yaml and members.yaml from SEED_HOME
# NOTE: This is NOT run automatically. Use --profile seed to run explicitly.
# Since tags are now managed via the admin UI, automatic seeding would
# overwrite user changes.
# ==========================================================================
seed:
image: ghcr.io/ipnet-mesh/meshcore-hub:${IMAGE_VERSION:-latest}
@@ -304,8 +307,6 @@ services:
dockerfile: Dockerfile
container_name: meshcore-seed
profiles:
- all
- core
- seed
restart: "no"
depends_on:
@@ -322,7 +323,7 @@ services:
- LOG_LEVEL=${LOG_LEVEL:-INFO}
# Explicitly unset to use DATA_HOME-based default path
- DATABASE_URL=
# Imports both node_tags.json and members.json if they exist
# Imports both node_tags.yaml and members.yaml if they exist
command: ["collector", "seed"]
# ==========================================================================

View File

@@ -7,12 +7,12 @@
# elevation: 150 # number
# is_online: true # boolean
#
# - Explicit type (for special types like coordinate):
# location:
# value: "37.7749,-122.4194"
# type: coordinate
# - Explicit type (when you need to force a specific type):
# altitude:
# value: "150"
# type: number
#
# Supported types: string, number, boolean, coordinate
# Supported types: string, number, boolean
0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef:
friendly_name: Gateway Node

View File

@@ -433,12 +433,12 @@ def import_tags_cmd(
\b
0123456789abcdef...:
friendly_name: My Node
location:
value: "52.0,1.0"
type: coordinate
altitude:
value: "150"
type: number
active:
value: "true"
type: boolean
Shorthand is also supported (string values with default type):
@@ -447,7 +447,7 @@ def import_tags_cmd(
friendly_name: My Node
role: gateway
Supported types: string, number, boolean, coordinate
Supported types: string, number, boolean
"""
from pathlib import Path

View File

@@ -19,7 +19,7 @@ class TagValue(BaseModel):
"""Schema for a tag value with type."""
value: str | None = None
type: str = Field(default="string", pattern=r"^(string|number|boolean|coordinate)$")
type: str = Field(default="string", pattern=r"^(string|number|boolean)$")
class NodeTags(BaseModel):

View File

@@ -21,7 +21,7 @@ class NodeTag(Base, UUIDMixin, TimestampMixin):
node_id: Foreign key to nodes table
key: Tag name/key
value: Tag value (stored as text, can be JSON for typed values)
value_type: Type hint (string, number, boolean, coordinate)
value_type: Type hint (string, number, boolean)
created_at: Record creation timestamp
updated_at: Record update timestamp
"""

View File

@@ -19,7 +19,7 @@ class NodeTagCreate(BaseModel):
default=None,
description="Tag value",
)
value_type: Literal["string", "number", "boolean", "coordinate"] = Field(
value_type: Literal["string", "number", "boolean"] = Field(
default="string",
description="Value type hint",
)
@@ -32,7 +32,7 @@ class NodeTagUpdate(BaseModel):
default=None,
description="Tag value",
)
value_type: Optional[Literal["string", "number", "boolean", "coordinate"]] = Field(
value_type: Optional[Literal["string", "number", "boolean"]] = Field(
default=None,
description="Value type hint",
)

View File

@@ -118,12 +118,18 @@ async def node_detail(request: Request, public_key: str) -> HTMLResponse:
logger.warning(f"Failed to fetch node details from API: {e}")
context["api_error"] = str(e)
# Check if admin editing is available
admin_enabled = getattr(request.app.state, "admin_enabled", False)
auth_user = request.headers.get("X-Forwarded-User")
context.update(
{
"node": node,
"advertisements": advertisements,
"telemetry": telemetry,
"public_key": public_key,
"admin_enabled": admin_enabled,
"is_authenticated": bool(auth_user),
}
)

View File

@@ -64,12 +64,12 @@
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<div class="flex justify-between items-start">
<div>
<h2 class="card-title">{{ selected_node.name or 'Unnamed Node' }}</h2>
<p class="text-sm opacity-70 font-mono">{{ selected_public_key }}</p>
{% if selected_node.adv_type %}
<span class="badge badge-outline mt-2">{{ selected_node.adv_type }}</span>
{% endif %}
<div class="flex items-start gap-3">
<span class="text-2xl" title="{{ selected_node.adv_type or 'Unknown' }}">{% if selected_node.adv_type and selected_node.adv_type|lower == 'chat' %}💬{% elif selected_node.adv_type and selected_node.adv_type|lower == 'repeater' %}📡{% elif selected_node.adv_type and selected_node.adv_type|lower == 'room' %}🪧{% else %}📍{% endif %}</span>
<div>
<h2 class="card-title">{{ selected_node.name or 'Unnamed Node' }}</h2>
<p class="text-sm opacity-70 font-mono">{{ selected_public_key }}</p>
</div>
</div>
<a href="/nodes/{{ selected_public_key }}" class="btn btn-ghost btn-sm">View Node</a>
</div>
@@ -158,8 +158,7 @@
<option value="string">string</option>
<option value="number">number</option>
<option value="boolean">boolean</option>
<option value="coordinate">coordinate</option>
</select>
</select>
</div>
<div class="form-control">
@@ -202,8 +201,7 @@
<option value="string">string</option>
<option value="number">number</option>
<option value="boolean">boolean</option>
<option value="coordinate">coordinate</option>
</select>
</select>
</div>
<div class="modal-action">

View File

@@ -97,9 +97,10 @@
<div class="grid grid-cols-1 {% if ns_map.lat and ns_map.lon %}lg:grid-cols-2{% endif %} gap-6 mt-6">
<!-- Tags -->
{% if node.tags %}
{% if node.tags or (admin_enabled and is_authenticated) %}
<div>
<h3 class="font-semibold opacity-70 mb-2">Tags</h3>
{% if node.tags %}
<div class="overflow-x-auto">
<table class="table table-compact w-full">
<thead>
@@ -120,6 +121,14 @@
</tbody>
</table>
</div>
{% else %}
<p class="text-sm opacity-70 mb-2">No tags defined.</p>
{% endif %}
{% if admin_enabled and is_authenticated %}
<div class="mt-3">
<a href="/a/node-tags?public_key={{ node.public_key }}" class="btn btn-sm btn-outline">{% if node.tags %}Edit Tags{% else %}Add Tags{% endif %}</a>
</div>
{% endif %}
</div>
{% endif %}

View File

@@ -99,7 +99,7 @@ class TestLoadTagsFile:
"""Test loading file with full format (value and type)."""
data = {
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef": {
"location": {"value": "52.0,1.0", "type": "coordinate"},
"is_active": {"value": "true", "type": "boolean"},
"altitude": {"value": "150", "type": "number"},
}
}
@@ -110,7 +110,7 @@ class TestLoadTagsFile:
result = load_tags_file(f.name)
key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
assert result[key]["location"]["type"] == "coordinate"
assert result[key]["is_active"]["type"] == "boolean"
assert result[key]["altitude"]["type"] == "number"
Path(f.name).unlink()

View File

@@ -55,14 +55,14 @@ class TestNodeModel:
def test_node_tags_relationship(self, db_session) -> None:
"""Test node-tag relationship."""
node = Node(public_key="b" * 64, name="Tagged Node")
tag = NodeTag(key="location", value="51.5,-0.1", value_type="coordinate")
tag = NodeTag(key="altitude", value="150", value_type="number")
node.tags.append(tag)
db_session.add(node)
db_session.commit()
assert len(node.tags) == 1
assert node.tags[0].key == "location"
assert node.tags[0].key == "altitude"
class TestMessageModel: