Compare commits
2 Commits
c6684ea61b
...
2e3fde2448
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e3fde2448 | |||
| c42bd1902c |
@@ -1,6 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import paho.mqtt.client as mqtt
|
import paho.mqtt.client as mqtt
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -94,6 +94,11 @@ class MQTTHandler:
|
|||||||
"last_seen": now.isoformat(),
|
"last_seen": now.isoformat(),
|
||||||
"assigned_system_id": None,
|
"assigned_system_id": None,
|
||||||
"approval_status": "pending",
|
"approval_status": "pending",
|
||||||
|
"node_type": payload.get("node_type", "fixed"),
|
||||||
|
"enforce_override_timeout": payload.get("enforce_override_timeout", True),
|
||||||
|
"is_overridden": False,
|
||||||
|
"override_system_id": None,
|
||||||
|
"override_timeout_at": None,
|
||||||
}
|
}
|
||||||
await fstore.doc_set("nodes", node_id, doc, merge=False)
|
await fstore.doc_set("nodes", node_id, doc, merge=False)
|
||||||
logger.info(f"New node registered: {node_id} — pending admin approval.")
|
logger.info(f"New node registered: {node_id} — pending admin approval.")
|
||||||
@@ -111,6 +116,37 @@ class MQTTHandler:
|
|||||||
elif existing.get("approval_status") == "approved":
|
elif existing.get("approval_status") == "approved":
|
||||||
# Approved but not yet configured — restore reachable status after reboot
|
# Approved but not yet configured — restore reachable status after reboot
|
||||||
updates["status"] = "unconfigured"
|
updates["status"] = "unconfigured"
|
||||||
|
|
||||||
|
node_type = payload.get("node_type") or existing.get("node_type") or "fixed"
|
||||||
|
enforce_timeout = payload.get("enforce_override_timeout")
|
||||||
|
if enforce_timeout is None:
|
||||||
|
enforce_timeout = existing.get("enforce_override_timeout", True)
|
||||||
|
|
||||||
|
updates["node_type"] = node_type
|
||||||
|
updates["enforce_override_timeout"] = enforce_timeout
|
||||||
|
|
||||||
|
is_overridden = payload.get("is_overridden", False)
|
||||||
|
override_system_id = payload.get("override_system_id")
|
||||||
|
|
||||||
|
if node_type == "portable":
|
||||||
|
updates["is_overridden"] = False
|
||||||
|
updates["override_system_id"] = None
|
||||||
|
updates["override_timeout_at"] = None
|
||||||
|
else:
|
||||||
|
updates["is_overridden"] = is_overridden
|
||||||
|
updates["override_system_id"] = override_system_id
|
||||||
|
|
||||||
|
if is_overridden:
|
||||||
|
existing_timeout = existing.get("override_timeout_at")
|
||||||
|
existing_override_id = existing.get("override_system_id")
|
||||||
|
if enforce_timeout:
|
||||||
|
if not existing_timeout or existing_override_id != override_system_id:
|
||||||
|
updates["override_timeout_at"] = (now + timedelta(hours=24)).isoformat()
|
||||||
|
else:
|
||||||
|
updates["override_timeout_at"] = None
|
||||||
|
else:
|
||||||
|
updates["override_timeout_at"] = None
|
||||||
|
|
||||||
await fstore.doc_update("nodes", node_id, updates)
|
await fstore.doc_update("nodes", node_id, updates)
|
||||||
|
|
||||||
# NOTE: discord_connected in checkins is informational only — do NOT release the
|
# NOTE: discord_connected in checkins is informational only — do NOT release the
|
||||||
|
|||||||
@@ -55,3 +55,35 @@ async def _sweep():
|
|||||||
logger.info(f"Node {node_id} marked offline (last seen: {last_seen.isoformat()})")
|
logger.info(f"Node {node_id} marked offline (last seen: {last_seen.isoformat()})")
|
||||||
from app.routers.tokens import release_token
|
from app.routers.tokens import release_token
|
||||||
await release_token(node_id)
|
await release_token(node_id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for expired system overrides (only for fixed nodes with timeout enforced)
|
||||||
|
override_timeout_raw = node.get("override_timeout_at")
|
||||||
|
enforce_timeout = node.get("enforce_override_timeout", True)
|
||||||
|
node_type = node.get("node_type", "fixed")
|
||||||
|
|
||||||
|
if override_timeout_raw and enforce_timeout and node_type != "portable":
|
||||||
|
if isinstance(override_timeout_raw, str):
|
||||||
|
override_timeout = datetime.fromisoformat(override_timeout_raw)
|
||||||
|
else:
|
||||||
|
override_timeout = override_timeout_raw
|
||||||
|
if override_timeout.tzinfo is None:
|
||||||
|
override_timeout = override_timeout.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
if datetime.now(timezone.utc) > override_timeout:
|
||||||
|
node_id = node.get("node_id")
|
||||||
|
assigned_system_id = node.get("assigned_system_id")
|
||||||
|
logger.info(f"Node {node_id} override has expired. Reverting to system {assigned_system_id}.")
|
||||||
|
|
||||||
|
# Push the original assigned config if it exists
|
||||||
|
if assigned_system_id:
|
||||||
|
system_doc = await fstore.doc_get("systems", assigned_system_id)
|
||||||
|
if system_doc:
|
||||||
|
from app.internal.mqtt_handler import mqtt_handler
|
||||||
|
mqtt_handler.push_config(node_id, system_doc)
|
||||||
|
|
||||||
|
await fstore.doc_update("nodes", node_id, {
|
||||||
|
"is_overridden": False,
|
||||||
|
"override_system_id": None,
|
||||||
|
"override_timeout_at": None,
|
||||||
|
})
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ class NodeRecord(BaseModel):
|
|||||||
configured: bool = False
|
configured: bool = False
|
||||||
last_seen: Optional[datetime] = None
|
last_seen: Optional[datetime] = None
|
||||||
assigned_system_id: Optional[str] = None
|
assigned_system_id: Optional[str] = None
|
||||||
|
node_type: str = "fixed" # fixed or portable
|
||||||
|
enforce_override_timeout: bool = True
|
||||||
|
is_overridden: bool = False
|
||||||
|
override_system_id: Optional[str] = None
|
||||||
|
override_timeout_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
class CommandPayload(BaseModel):
|
class CommandPayload(BaseModel):
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import secrets
|
import secrets
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
from app.models import CommandPayload
|
from app.models import CommandPayload
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
from app.internal.mqtt_handler import mqtt_handler
|
from app.internal.mqtt_handler import mqtt_handler
|
||||||
@@ -126,10 +127,13 @@ async def assign_system(
|
|||||||
if not system:
|
if not system:
|
||||||
raise HTTPException(404, f"System '{system_id}' not found.")
|
raise HTTPException(404, f"System '{system_id}' not found.")
|
||||||
|
|
||||||
# Include hardware preset in the push so the edge node applies it when
|
# Include hardware preset, node type, and enforce timeout in the push
|
||||||
# generating the OP25 config. Strip it from the system doc first so it
|
push_payload = {
|
||||||
# doesn't collide with SystemConfig field validation on the node side.
|
**system,
|
||||||
push_payload = {**system, "hardware_preset": hardware_preset}
|
"hardware_preset": hardware_preset,
|
||||||
|
"node_type": node.get("node_type", "fixed"),
|
||||||
|
"enforce_override_timeout": node.get("enforce_override_timeout", True),
|
||||||
|
}
|
||||||
if ppm_override is not None:
|
if ppm_override is not None:
|
||||||
push_payload["ppm_override"] = ppm_override
|
push_payload["ppm_override"] = ppm_override
|
||||||
mqtt_handler.push_config(node_id, push_payload)
|
mqtt_handler.push_config(node_id, push_payload)
|
||||||
@@ -145,3 +149,89 @@ async def assign_system(
|
|||||||
await fstore.doc_update("nodes", node_id, node_updates)
|
await fstore.doc_update("nodes", node_id, node_updates)
|
||||||
|
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
class NodeUpdateBody(BaseModel):
|
||||||
|
node_type: Optional[str] = None
|
||||||
|
enforce_override_timeout: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{node_id}")
|
||||||
|
async def update_node(
|
||||||
|
node_id: str,
|
||||||
|
body: NodeUpdateBody,
|
||||||
|
_: dict = Depends(require_admin_token),
|
||||||
|
):
|
||||||
|
node = await fstore.doc_get("nodes", node_id)
|
||||||
|
if not node:
|
||||||
|
raise HTTPException(404, f"Node '{node_id}' not found.")
|
||||||
|
|
||||||
|
updates = body.model_dump(exclude_unset=True)
|
||||||
|
if not updates:
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
await fstore.doc_update("nodes", node_id, updates)
|
||||||
|
|
||||||
|
# Re-push config to apply new node settings locally
|
||||||
|
updated_node = await fstore.doc_get("nodes", node_id)
|
||||||
|
assigned_system_id = updated_node.get("assigned_system_id")
|
||||||
|
if assigned_system_id:
|
||||||
|
system = await fstore.doc_get("systems", assigned_system_id)
|
||||||
|
if system:
|
||||||
|
push_payload = {
|
||||||
|
**system,
|
||||||
|
"hardware_preset": updated_node.get("hardware_preset", "rtl-sdr-v3"),
|
||||||
|
"node_type": updated_node.get("node_type", "fixed"),
|
||||||
|
"enforce_override_timeout": updated_node.get("enforce_override_timeout", True),
|
||||||
|
}
|
||||||
|
if updated_node.get("ppm_override") is not None:
|
||||||
|
push_payload["ppm_override"] = updated_node["ppm_override"]
|
||||||
|
mqtt_handler.push_config(node_id, push_payload)
|
||||||
|
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
class AckOverrideBody(BaseModel):
|
||||||
|
timeout_minutes: int = 1440
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{node_id}/override/ack")
|
||||||
|
async def ack_override(
|
||||||
|
node_id: str,
|
||||||
|
body: AckOverrideBody,
|
||||||
|
_: dict = Depends(require_service_key_or_admin),
|
||||||
|
):
|
||||||
|
node = await fstore.doc_get("nodes", node_id)
|
||||||
|
if not node:
|
||||||
|
raise HTTPException(404, f"Node '{node_id}' not found.")
|
||||||
|
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
new_timeout = datetime.now(timezone.utc) + timedelta(minutes=body.timeout_minutes)
|
||||||
|
|
||||||
|
await fstore.doc_update("nodes", node_id, {
|
||||||
|
"override_timeout_at": new_timeout.isoformat()
|
||||||
|
})
|
||||||
|
return {"ok": True, "override_timeout_at": new_timeout.isoformat()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{node_id}/override/reset")
|
||||||
|
async def reset_override(
|
||||||
|
node_id: str,
|
||||||
|
_: dict = Depends(require_service_key_or_admin),
|
||||||
|
):
|
||||||
|
node = await fstore.doc_get("nodes", node_id)
|
||||||
|
if not node:
|
||||||
|
raise HTTPException(404, f"Node '{node_id}' not found.")
|
||||||
|
|
||||||
|
assigned_system_id = node.get("assigned_system_id")
|
||||||
|
if assigned_system_id:
|
||||||
|
system = await fstore.doc_get("systems", assigned_system_id)
|
||||||
|
if system:
|
||||||
|
mqtt_handler.push_config(node_id, system)
|
||||||
|
|
||||||
|
await fstore.doc_update("nodes", node_id, {
|
||||||
|
"is_overridden": False,
|
||||||
|
"override_system_id": None,
|
||||||
|
"override_timeout_at": None,
|
||||||
|
})
|
||||||
|
return {"ok": True}
|
||||||
|
|||||||
@@ -192,6 +192,54 @@ export default function NodeDetailPage() {
|
|||||||
<StatusBadge status={node.status} />
|
<StatusBadge status={node.status} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Override Warning */}
|
||||||
|
{node.is_overridden && (
|
||||||
|
<div className="bg-yellow-950/40 border border-yellow-800/60 rounded-lg p-4 font-mono text-sm space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-yellow-400 font-semibold">
|
||||||
|
<span className="text-base">⚠</span>
|
||||||
|
<span>Local System Override Active</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-400 text-xs leading-relaxed">
|
||||||
|
This node is operating on a local system override.
|
||||||
|
{node.override_timeout_at ? (
|
||||||
|
<> Resets automatically on: <span className="text-white font-bold">{new Date(node.override_timeout_at).toLocaleString()}</span>.</>
|
||||||
|
) : (
|
||||||
|
<> No timeout is currently enforced (permanent override).</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{node.override_timeout_at && (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await c2api.ackOverride(id, 1440);
|
||||||
|
} catch (e) {
|
||||||
|
alert("Failed to extend timer.");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-3 py-1 bg-yellow-800 hover:bg-yellow-700 text-white rounded text-xs transition-colors"
|
||||||
|
>
|
||||||
|
Ack (Reset 24h Timer)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (confirm("Force this node to revert back to its assigned system config?")) {
|
||||||
|
try {
|
||||||
|
await c2api.resetOverride(id);
|
||||||
|
} catch (e) {
|
||||||
|
alert("Failed to reset override.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-3 py-1 bg-red-900 hover:bg-red-800 text-red-200 rounded text-xs transition-colors"
|
||||||
|
>
|
||||||
|
Force Revert Config
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Info */}
|
{/* Info */}
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-lg divide-y divide-gray-800 font-mono text-sm">
|
<div className="bg-gray-900 border border-gray-800 rounded-lg divide-y divide-gray-800 font-mono text-sm">
|
||||||
{[
|
{[
|
||||||
@@ -199,6 +247,8 @@ export default function NodeDetailPage() {
|
|||||||
["Location", `${node.lat}, ${node.lon}`],
|
["Location", `${node.lat}, ${node.lon}`],
|
||||||
["Last Seen", node.last_seen ? new Date(node.last_seen).toLocaleString() : "never"],
|
["Last Seen", node.last_seen ? new Date(node.last_seen).toLocaleString() : "never"],
|
||||||
["Configured", node.configured ? "Yes" : "No"],
|
["Configured", node.configured ? "Yes" : "No"],
|
||||||
|
["Node Type", node.node_type ?? "fixed"],
|
||||||
|
...(node.node_type !== "portable" ? [["Enforce Timeout", node.enforce_override_timeout ? "Yes" : "No"]] : []),
|
||||||
].map(([label, value]) => (
|
].map(([label, value]) => (
|
||||||
<div key={label} className="flex justify-between px-4 py-2.5">
|
<div key={label} className="flex justify-between px-4 py-2.5">
|
||||||
<span className="text-gray-500">{label}</span>
|
<span className="text-gray-500">{label}</span>
|
||||||
|
|||||||
@@ -45,6 +45,18 @@ export function NodeCard({ node, system }: Props) {
|
|||||||
⚠ Needs configuration
|
⚠ Needs configuration
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{node.is_overridden && (
|
||||||
|
<div className="mt-3 text-xs text-yellow-500 font-mono border-t border-gray-800 pt-2 flex justify-between">
|
||||||
|
<span>⚠ Local Override</span>
|
||||||
|
{node.override_timeout_at ? (
|
||||||
|
<span className="text-gray-500">
|
||||||
|
Resets: {new Date(node.override_timeout_at).toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-500">Permanent</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ const PRESETS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function NodeConfigModal({ node, systems, onClose }: Props) {
|
export function NodeConfigModal({ node, systems, onClose }: Props) {
|
||||||
const [systemId, setSystemId] = useState("");
|
const [systemId, setSystemId] = useState(node.assigned_system_id ?? "");
|
||||||
const [preset, setPreset] = useState("rtl-sdr-v3");
|
const [preset, setPreset] = useState(node.hardware_preset ?? "rtl-sdr-v3");
|
||||||
const [ppm, setPpm] = useState("0");
|
const [ppm, setPpm] = useState(node.ppm_override ? String(node.ppm_override) : "0");
|
||||||
|
const [nodeType, setNodeType] = useState(node.node_type ?? "");
|
||||||
|
const [enforceTimeout, setEnforceTimeout] = useState(node.enforce_override_timeout ?? true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -32,6 +34,10 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
await c2api.updateNode(node.node_id, {
|
||||||
|
node_type: nodeType,
|
||||||
|
enforce_override_timeout: enforceTimeout,
|
||||||
|
});
|
||||||
await c2api.assignSystem(node.node_id, systemId, preset, ppmOverride);
|
await c2api.assignSystem(node.node_id, systemId, preset, ppmOverride);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -100,12 +106,41 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-400 mb-1">Node Type *</label>
|
||||||
|
<select
|
||||||
|
value={nodeType}
|
||||||
|
onChange={(e) => setNodeType(e.target.value)}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Select node type...</option>
|
||||||
|
<option value="fixed">Fixed Node (Standard)</option>
|
||||||
|
<option value="portable">Portable Node (Handheld)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{nodeType === "fixed" && (
|
||||||
|
<div className="flex items-center gap-2 py-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="enforceTimeout"
|
||||||
|
checked={enforceTimeout}
|
||||||
|
onChange={(e) => setEnforceTimeout(e.target.checked)}
|
||||||
|
className="rounded bg-gray-800 border-gray-700 text-indigo-600 focus:ring-indigo-500 focus:ring-offset-gray-900"
|
||||||
|
/>
|
||||||
|
<label htmlFor="enforceTimeout" className="text-xs text-gray-400 cursor-pointer select-none">
|
||||||
|
Enforce Local Override Timeout (24 hours)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && <p className="text-red-400 text-xs">{error}</p>}
|
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||||
|
|
||||||
<div className="flex gap-3 pt-1">
|
<div className="flex gap-3 pt-1">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saving || !systemId}
|
disabled={saving || !systemId || !nodeType}
|
||||||
className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg py-2 text-sm font-semibold transition-colors"
|
className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg py-2 text-sm font-semibold transition-colors"
|
||||||
>
|
>
|
||||||
{saving ? "Saving…" : "Assign & Configure"}
|
{saving ? "Saving…" : "Assign & Configure"}
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ export const c2api = {
|
|||||||
if (ppmOverride !== undefined) params.set("ppm_override", String(ppmOverride));
|
if (ppmOverride !== undefined) params.set("ppm_override", String(ppmOverride));
|
||||||
return request(`/nodes/${nodeId}/config/${systemId}?${params}`, { method: "POST" });
|
return request(`/nodes/${nodeId}/config/${systemId}?${params}`, { method: "POST" });
|
||||||
},
|
},
|
||||||
|
ackOverride: (nodeId: string, timeoutMinutes: number = 1440) =>
|
||||||
|
request(`/nodes/${nodeId}/override/ack`, { method: "POST", body: JSON.stringify({ timeout_minutes: timeoutMinutes }) }),
|
||||||
|
resetOverride: (nodeId: string) =>
|
||||||
|
request(`/nodes/${nodeId}/override/reset`, { method: "POST" }),
|
||||||
|
updateNode: (id: string, body: { node_type?: string; enforce_override_timeout?: boolean }) =>
|
||||||
|
request(`/nodes/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||||
|
|
||||||
// Systems
|
// Systems
|
||||||
getSystems: () => request<unknown[]>("/systems"),
|
getSystems: () => request<unknown[]>("/systems"),
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ export interface NodeRecord {
|
|||||||
approval_status: ApprovalStatus | null;
|
approval_status: ApprovalStatus | null;
|
||||||
hardware_preset?: string;
|
hardware_preset?: string;
|
||||||
ppm_override?: number | null;
|
ppm_override?: number | null;
|
||||||
|
node_type?: string;
|
||||||
|
enforce_override_timeout?: boolean;
|
||||||
|
is_overridden?: boolean;
|
||||||
|
override_system_id?: string | null;
|
||||||
|
override_timeout_at?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VocabularyPendingTerm {
|
export interface VocabularyPendingTerm {
|
||||||
|
|||||||
Reference in New Issue
Block a user