mirror of
https://github.com/renorris/openfsd
synced 2026-04-28 20:25:31 +08:00
add kick user web interface
This commit is contained in:
@@ -3,6 +3,7 @@ package fsd
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/renorris/openfsd/db"
|
"github.com/renorris/openfsd/db"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -27,6 +28,7 @@ func (s *Server) setupRoutes() (e *gin.Engine) {
|
|||||||
// Verify administrator service JWT
|
// Verify administrator service JWT
|
||||||
e.Use(s.authMiddleware)
|
e.Use(s.authMiddleware)
|
||||||
e.GET("/online_users", s.handleGetOnlineUsers)
|
e.GET("/online_users", s.handleGetOnlineUsers)
|
||||||
|
e.POST("/kick_user", s.handleKickUser)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -143,3 +145,29 @@ func (s *Server) handleGetOnlineUsers(c *gin.Context) {
|
|||||||
c.Writer.WriteHeader(http.StatusOK)
|
c.Writer.WriteHeader(http.StatusOK)
|
||||||
json.NewEncoder(c.Writer).Encode(&resData)
|
json.NewEncoder(c.Writer).Encode(&resData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleKickUser(c *gin.Context) {
|
||||||
|
type RequestBody struct {
|
||||||
|
Callsign string `json:"callsign" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var reqBody RequestBody
|
||||||
|
if err := c.ShouldBindJSON(&reqBody); err != nil {
|
||||||
|
c.AbortWithStatus(http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := s.postOffice.find(reqBody.Callsign)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, ErrCallsignDoesNotExist) {
|
||||||
|
c.AbortWithStatus(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.AbortWithStatus(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancelling the context will cause the client's event loop to close
|
||||||
|
client.cancelCtx()
|
||||||
|
|
||||||
|
c.AbortWithStatus(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|||||||
11
web/auth.go
11
web/auth.go
@@ -231,7 +231,16 @@ func (s *Server) jwtBearerMiddleware(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setJwtContext(c, accessToken.CustomClaims())
|
claims := accessToken.CustomClaims()
|
||||||
|
|
||||||
|
if claims.TokenType != "access" {
|
||||||
|
res := newAPIV1Failure("invalid token type")
|
||||||
|
writeAPIV1Response(c, http.StatusUnauthorized, &res)
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setJwtContext(c, claims)
|
||||||
|
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
|
|||||||
58
web/data.go
58
web/data.go
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/renorris/openfsd/db"
|
"github.com/renorris/openfsd/db"
|
||||||
"github.com/renorris/openfsd/fsd"
|
"github.com/renorris/openfsd/fsd"
|
||||||
"go.uber.org/atomic"
|
"go.uber.org/atomic"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -272,32 +273,11 @@ func (s *Server) getDatafeed(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) generateDatafeed() (feed *DatafeedCache, err error) {
|
func (s *Server) generateDatafeed() (feed *DatafeedCache, err error) {
|
||||||
// Generate JWT bearer token
|
|
||||||
customFields := fsd.CustomFields{
|
|
||||||
TokenType: "fsd_service",
|
|
||||||
CID: -1,
|
|
||||||
NetworkRating: fsd.NetworkRatingAdministator,
|
|
||||||
}
|
|
||||||
token, err := fsd.MakeJwtToken(&customFields, 15*time.Minute)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
secretKey, err := s.dbRepo.ConfigRepo.Get(db.ConfigJwtSecretKey)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tokenStr, err := token.SignedString([]byte(secretKey))
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
client := http.Client{}
|
client := http.Client{}
|
||||||
req, err := http.NewRequest("GET", s.cfg.FsdHttpServiceAddress+"/online_users", nil)
|
req, err := s.makeFsdHttpServiceHttpRequest("GET", "/online_users", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Authorization", "Bearer "+tokenStr)
|
|
||||||
res, err := client.Do(req)
|
res, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@@ -331,6 +311,40 @@ func (s *Server) generateDatafeed() (feed *DatafeedCache, err error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// makeFsdHttpServiceHttpRequest prepares an HTTP request destined for the internal FSD HTTP API.
|
||||||
|
//
|
||||||
|
// method sets the HTTP method, path is the relative HTTP path (e.g. /online_users), and body is an optional request body.
|
||||||
|
func (s *Server) makeFsdHttpServiceHttpRequest(method string, path string, body io.Reader) (req *http.Request, err error) {
|
||||||
|
// Generate JWT bearer token
|
||||||
|
customFields := fsd.CustomFields{
|
||||||
|
TokenType: "fsd_service",
|
||||||
|
CID: -1,
|
||||||
|
NetworkRating: fsd.NetworkRatingAdministator,
|
||||||
|
}
|
||||||
|
token, err := fsd.MakeJwtToken(&customFields, 15*time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
secretKey, err := s.dbRepo.ConfigRepo.Get(db.ConfigJwtSecretKey)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tokenStr, err := token.SignedString([]byte(secretKey))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
url := s.cfg.FsdHttpServiceAddress + path
|
||||||
|
req, err = http.NewRequest(method, url, body)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tokenStr)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) runDatafeedWorker(ctx context.Context) {
|
func (s *Server) runDatafeedWorker(ctx context.Context) {
|
||||||
s.updateDataFeedCache()
|
s.updateDataFeedCache()
|
||||||
ticker := time.NewTicker(15 * time.Second)
|
ticker := time.NewTicker(15 * time.Second)
|
||||||
|
|||||||
57
web/fsdconn.go
Normal file
57
web/fsdconn.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/renorris/openfsd/fsd"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleKickActiveConnection(c *gin.Context) {
|
||||||
|
claims := getJwtContext(c)
|
||||||
|
if claims.NetworkRating < fsd.NetworkRatingSupervisor {
|
||||||
|
writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequestBody struct {
|
||||||
|
Callsign string `json:"callsign" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var reqBody RequestBody
|
||||||
|
if !bindJSONOrAbort(c, &reqBody) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := bytes.Buffer{}
|
||||||
|
if err := json.NewEncoder(&buf).Encode(reqBody); err != nil {
|
||||||
|
writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client := http.Client{}
|
||||||
|
defer client.CloseIdleConnections()
|
||||||
|
req, err := s.makeFsdHttpServiceHttpRequest("POST", "/kick_user", &buf)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch res.StatusCode {
|
||||||
|
case http.StatusNoContent:
|
||||||
|
apiV1Res := newAPIV1Success(nil)
|
||||||
|
writeAPIV1Response(c, http.StatusOK, &apiV1Res)
|
||||||
|
return
|
||||||
|
case http.StatusNotFound:
|
||||||
|
apiV1Res := newAPIV1Failure("Callsign not found")
|
||||||
|
writeAPIV1Response(c, http.StatusNotFound, &apiV1Res)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ func (s *Server) setupRoutes() (e *gin.Engine) {
|
|||||||
s.setupUserRoutes(apiV1Group)
|
s.setupUserRoutes(apiV1Group)
|
||||||
s.setupConfigRoutes(apiV1Group)
|
s.setupConfigRoutes(apiV1Group)
|
||||||
s.setupDataRoutes(apiV1Group)
|
s.setupDataRoutes(apiV1Group)
|
||||||
|
s.setupFsdConnRoutes(apiV1Group)
|
||||||
|
|
||||||
// Frontend groups
|
// Frontend groups
|
||||||
s.setupFrontendRoutes(e.Group(""))
|
s.setupFrontendRoutes(e.Group(""))
|
||||||
@@ -67,6 +68,12 @@ func (s *Server) setupConfigRoutes(parent *gin.RouterGroup) {
|
|||||||
configGroup.POST("/createtoken", s.handleCreateNewAPIToken)
|
configGroup.POST("/createtoken", s.handleCreateNewAPIToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) setupFsdConnRoutes(parent *gin.RouterGroup) {
|
||||||
|
fsdConnGroup := parent.Group("/fsdconn")
|
||||||
|
fsdConnGroup.Use(s.jwtBearerMiddleware)
|
||||||
|
fsdConnGroup.POST("/kickuser", s.handleKickActiveConnection)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) setupDataRoutes(parent *gin.RouterGroup) {
|
func (s *Server) setupDataRoutes(parent *gin.RouterGroup) {
|
||||||
dataGroup := parent.Group("/data")
|
dataGroup := parent.Group("/data")
|
||||||
dataGroup.GET("/status.txt", s.handleGetStatusTxt)
|
dataGroup.GET("/status.txt", s.handleGetStatusTxt)
|
||||||
|
|||||||
@@ -1,3 +1,14 @@
|
|||||||
|
let userNetworkRating;
|
||||||
|
|
||||||
|
async function kickUser(callsign) {
|
||||||
|
try {
|
||||||
|
await doAPIRequestWithAuth("POST", "/api/v1/fsdconn/kickuser", { callsign: callsign });
|
||||||
|
alert("User kicked successfully");
|
||||||
|
} catch (error) {
|
||||||
|
alert("Failed to kick user");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function networkRatingFromInt(val) {
|
function networkRatingFromInt(val) {
|
||||||
switch (val) {
|
switch (val) {
|
||||||
case -1: return "Inactive"
|
case -1: return "Inactive"
|
||||||
@@ -18,9 +29,9 @@ function networkRatingFromInt(val) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$(document).ready(async () => {
|
$(async () => {
|
||||||
const claims = getAccessTokenClaims()
|
const claims = getAccessTokenClaims()
|
||||||
loadUserInfo(claims.cid)
|
await loadUserInfo(claims.cid)
|
||||||
|
|
||||||
const map = L.map('map').setView([30, 0], 1);
|
const map = L.map('map').setView([30, 0], 1);
|
||||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
@@ -35,11 +46,12 @@ $(document).ready(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await populateMap(map, planeIcon)
|
await populateMap(map, planeIcon)
|
||||||
setInterval(() => { populateMap(map, planeIcon) }, 5000)
|
setInterval(() => { populateMap(map, planeIcon) }, 15000)
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadUserInfo(cid) {
|
async function loadUserInfo(cid) {
|
||||||
const res = await doAPIRequest("POST", "/api/v1/user/load", true, { cid: cid })
|
const res = await doAPIRequest("POST", "/api/v1/user/load", true, { cid: cid })
|
||||||
|
userNetworkRating = res.data.network_rating;
|
||||||
|
|
||||||
if (res.data.first_name !== "") {
|
if (res.data.first_name !== "") {
|
||||||
$("#dashboard-real-name").text(`Welcome, ${res.data.first_name}!`);
|
$("#dashboard-real-name").text(`Welcome, ${res.data.first_name}!`);
|
||||||
@@ -62,17 +74,26 @@ let dashboardMarkers = [];
|
|||||||
|
|
||||||
async function populateMap(map, planeIcon) {
|
async function populateMap(map, planeIcon) {
|
||||||
try {
|
try {
|
||||||
const res = await $.ajax("https://data.vatsim.net/v3/vatsim-data.json", {
|
const res = await $.ajax("/api/v1/data/openfsd-data.json", {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
dataType: "json"
|
dataType: "json"
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Collect callsigns of markers with open popups
|
||||||
|
const openCallsigns = new Set();
|
||||||
|
dashboardMarkers.forEach((marker) => {
|
||||||
|
if (marker.getPopup() && marker.getPopup().isOpen()) {
|
||||||
|
openCallsigns.add(marker.options.title);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Remove existing markers
|
// Remove existing markers
|
||||||
dashboardMarkers.forEach((marker) => {
|
dashboardMarkers.forEach((marker) => {
|
||||||
map.removeLayer(marker);
|
map.removeLayer(marker);
|
||||||
});
|
});
|
||||||
dashboardMarkers = [];
|
dashboardMarkers = [];
|
||||||
|
|
||||||
|
// Add new markers
|
||||||
res.pilots.forEach((pilot) => {
|
res.pilots.forEach((pilot) => {
|
||||||
const callsign = pilot.callsign;
|
const callsign = pilot.callsign;
|
||||||
const lat = pilot.latitude;
|
const lat = pilot.latitude;
|
||||||
@@ -80,20 +101,25 @@ async function populateMap(map, planeIcon) {
|
|||||||
const heading = pilot.heading;
|
const heading = pilot.heading;
|
||||||
const name = pilot.name;
|
const name = pilot.name;
|
||||||
|
|
||||||
const marker = L.marker([lat, lon], { icon: planeIcon, title: callsign });
|
const marker = L.marker([lat, lon], {
|
||||||
// Bind popup with callsign
|
icon: planeIcon,
|
||||||
marker.bindPopup(`<b>Callsign:</b> ${callsign}<br>${name}<br>${lat} ${lon}`).openPopup();
|
rotationAngle: heading,
|
||||||
marker.on('click', function() {
|
rotationOrigin: 'center center',
|
||||||
this.openPopup();
|
title: callsign
|
||||||
});
|
});
|
||||||
marker.addTo(map);
|
|
||||||
// Set rotation
|
let popupContent = `<b>Callsign:</b> ${callsign}<br>${name}<br>${lat} ${lon}`;
|
||||||
if (marker._icon) {
|
if (userNetworkRating >= 11) {
|
||||||
const currentTransform = marker._icon.style.transform || '';
|
popupContent += `<br><button onclick="kickUser('${callsign}')">Kick</button>`;
|
||||||
marker._icon.style.transform = currentTransform + ` rotate(${heading}deg)`;
|
|
||||||
marker._icon.style.transformOrigin = "center"
|
|
||||||
}
|
}
|
||||||
|
marker.bindPopup(popupContent);
|
||||||
|
marker.addTo(map);
|
||||||
dashboardMarkers.push(marker);
|
dashboardMarkers.push(marker);
|
||||||
|
|
||||||
|
// If this callsign was open before, open its popup
|
||||||
|
if (openCallsigns.has(callsign)) {
|
||||||
|
marker.openPopup();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
$("#dashboard-connection-count").text(dashboardMarkers.length);
|
$("#dashboard-connection-count").text(dashboardMarkers.length);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
85
web/static/js/openfsd/leaflet.rotatedmarker.js
Normal file
85
web/static/js/openfsd/leaflet.rotatedmarker.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/*
|
||||||
|
|
||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2015 Benjamin Becquet
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* https://github.com/bbecquet/Leaflet.RotatedMarker */
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
// save these original methods before they are overwritten
|
||||||
|
var proto_initIcon = L.Marker.prototype._initIcon;
|
||||||
|
var proto_setPos = L.Marker.prototype._setPos;
|
||||||
|
|
||||||
|
var oldIE = (L.DomUtil.TRANSFORM === 'msTransform');
|
||||||
|
|
||||||
|
L.Marker.addInitHook(function () {
|
||||||
|
var iconOptions = this.options.icon && this.options.icon.options;
|
||||||
|
var iconAnchor = iconOptions && this.options.icon.options.iconAnchor;
|
||||||
|
if (iconAnchor) {
|
||||||
|
iconAnchor = (iconAnchor[0] + 'px ' + iconAnchor[1] + 'px');
|
||||||
|
}
|
||||||
|
this.options.rotationOrigin = this.options.rotationOrigin || iconAnchor || 'center bottom' ;
|
||||||
|
this.options.rotationAngle = this.options.rotationAngle || 0;
|
||||||
|
|
||||||
|
// Ensure marker keeps rotated during dragging
|
||||||
|
this.on('drag', function(e) { e.target._applyRotation(); });
|
||||||
|
});
|
||||||
|
|
||||||
|
L.Marker.include({
|
||||||
|
_initIcon: function() {
|
||||||
|
proto_initIcon.call(this);
|
||||||
|
},
|
||||||
|
|
||||||
|
_setPos: function (pos) {
|
||||||
|
proto_setPos.call(this, pos);
|
||||||
|
this._applyRotation();
|
||||||
|
},
|
||||||
|
|
||||||
|
_applyRotation: function () {
|
||||||
|
if(this.options.rotationAngle) {
|
||||||
|
this._icon.style[L.DomUtil.TRANSFORM+'Origin'] = this.options.rotationOrigin;
|
||||||
|
|
||||||
|
if(oldIE) {
|
||||||
|
// for IE 9, use the 2D rotation
|
||||||
|
this._icon.style[L.DomUtil.TRANSFORM] = 'rotate(' + this.options.rotationAngle + 'deg)';
|
||||||
|
} else {
|
||||||
|
// for modern browsers, prefer the 3D accelerated version
|
||||||
|
this._icon.style[L.DomUtil.TRANSFORM] += ' rotateZ(' + this.options.rotationAngle + 'deg)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setRotationAngle: function(angle) {
|
||||||
|
this.options.rotationAngle = angle;
|
||||||
|
this.update();
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
setRotationOrigin: function(origin) {
|
||||||
|
this.options.rotationOrigin = origin;
|
||||||
|
this.update();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -15,5 +15,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/leaflet.js"></script>
|
||||||
|
<script src="/static/js/openfsd/leaflet.rotatedmarker.js"></script>
|
||||||
|
<link href="/static/css/leaflet.css" rel="stylesheet">
|
||||||
|
|
||||||
<script src="/static/js/openfsd/dashboard.js" defer></script>
|
<script src="/static/js/openfsd/dashboard.js" defer></script>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
@@ -9,9 +9,6 @@
|
|||||||
<link href="/static/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-SgOJa3DmI69IUzQ2PVdRZhwQ+dy64/BUtbMJw1MZ8t5HZApcHrRKUc4W0kG879m7">
|
<link href="/static/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-SgOJa3DmI69IUzQ2PVdRZhwQ+dy64/BUtbMJw1MZ8t5HZApcHrRKUc4W0kG879m7">
|
||||||
<script src="/static/js/bootstrap.bundle.min.js" integrity="sha384-k6d4wzSIapyDyv1kpU366/PK5hCdSbCRGRCMv+eplOQJWyd1fbcAu9OCUj5zNLiq"></script>
|
<script src="/static/js/bootstrap.bundle.min.js" integrity="sha384-k6d4wzSIapyDyv1kpU366/PK5hCdSbCRGRCMv+eplOQJWyd1fbcAu9OCUj5zNLiq"></script>
|
||||||
|
|
||||||
<script src="/static/js/leaflet.js"></script>
|
|
||||||
<link href="/static/css/leaflet.css" rel="stylesheet">
|
|
||||||
|
|
||||||
<script src="/static/js/openfsd/api.js"></script>
|
<script src="/static/js/openfsd/api.js"></script>
|
||||||
|
|
||||||
<title>openfsd - {{ template "title" . }}</title>
|
<title>openfsd - {{ template "title" . }}</title>
|
||||||
|
|||||||
Reference in New Issue
Block a user