View datapoint values
This commit is contained in:
parent
5f6a48e7e0
commit
d72694a8b4
35
datapoint.go
35
datapoint.go
@ -2,7 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
// External
|
||||
we "git.gibonuddevalla.se/go/wrappederror"
|
||||
werr "git.gibonuddevalla.se/go/wrappederror"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
// Standard
|
||||
@ -94,7 +94,7 @@ func DatapointAdd[T any](name string, value T) (err error) { // {{{
|
||||
|
||||
err = row.Scan(&dpID, &dpType)
|
||||
if err != nil {
|
||||
err = we.Wrap(err).WithData(struct {
|
||||
err = werr.Wrap(err).WithData(struct {
|
||||
Name string
|
||||
Value any
|
||||
}{name, value})
|
||||
@ -110,7 +110,7 @@ func DatapointAdd[T any](name string, value T) (err error) { // {{{
|
||||
_, err = service.Db.Conn.Exec(`INSERT INTO datapoint_value(datapoint_id, value_datetime) VALUES($1, $2)`, dpID, value)
|
||||
}
|
||||
if err != nil {
|
||||
err = we.Wrap(err).WithData(struct {
|
||||
err = werr.Wrap(err).WithData(struct {
|
||||
ID int
|
||||
value any
|
||||
}{dpID, value})
|
||||
@ -147,7 +147,7 @@ func DatapointsRetrieve() (dps []Datapoint, err error) { // {{{
|
||||
dp.name ASC
|
||||
`)
|
||||
if err != nil {
|
||||
err = we.Wrap(err)
|
||||
err = werr.Wrap(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
@ -170,7 +170,7 @@ func DatapointsRetrieve() (dps []Datapoint, err error) { // {{{
|
||||
res := DbRes{}
|
||||
err = rows.StructScan(&res)
|
||||
if err != nil {
|
||||
err = we.Wrap(err)
|
||||
err = werr.Wrap(err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -218,7 +218,7 @@ func DatapointRetrieve(id int, name string) (dp Datapoint, err error) { // {{{
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
err = we.Wrap(err).WithData(name)
|
||||
err = werr.Wrap(err).WithData(name)
|
||||
return
|
||||
}
|
||||
|
||||
@ -238,7 +238,7 @@ func DatapointRetrieve(id int, name string) (dp Datapoint, err error) { // {{{
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
err = we.Wrap(err).WithData(dp.ID)
|
||||
err = werr.Wrap(err).WithData(dp.ID)
|
||||
return
|
||||
}
|
||||
|
||||
@ -247,7 +247,26 @@ func DatapointRetrieve(id int, name string) (dp Datapoint, err error) { // {{{
|
||||
func DatapointDelete(id int) (err error) {// {{{
|
||||
_, err = service.Db.Conn.Exec(`DELETE FROM datapoint WHERE id=$1`, id)
|
||||
if err != nil {
|
||||
err = we.Wrap(err).WithData(id)
|
||||
err = werr.Wrap(err).WithData(id)
|
||||
}
|
||||
return
|
||||
}// }}}
|
||||
func DatapointValues(id int) (values []DatapointValue, err error) {// {{{
|
||||
rows, err := service.Db.Conn.Queryx(`SELECT * FROM datapoint_value WHERE datapoint_id=$1 ORDER BY ts DESC LIMIT 500`, id)
|
||||
if err != nil {
|
||||
err = werr.Wrap(err).WithData(id)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
dpv := DatapointValue{}
|
||||
err = rows.StructScan(&dpv)
|
||||
if err != nil {
|
||||
err = werr.Wrap(err).WithData(id)
|
||||
return
|
||||
}
|
||||
values = append(values, dpv)
|
||||
}
|
||||
return
|
||||
}// }}}
|
||||
|
98
main.go
98
main.go
@ -122,6 +122,7 @@ func main() { // {{{
|
||||
service.Register("/datapoint/edit/{id}", false, false, pageDatapointEdit)
|
||||
service.Register("/datapoint/update/{id}", false, false, pageDatapointUpdate)
|
||||
service.Register("/datapoint/delete/{id}", false, false, pageDatapointDelete)
|
||||
service.Register("/datapoint/values/{id}", false, false, pageDatapointValues)
|
||||
|
||||
service.Register("/triggers", false, false, pageTriggers)
|
||||
service.Register("/trigger/edit/{id}", false, false, pageTriggerEdit)
|
||||
@ -194,6 +195,8 @@ func entryDatapoint(w http.ResponseWriter, r *http.Request, sess *session.T) { /
|
||||
if err != nil {
|
||||
logger.Error("entry", "error", err)
|
||||
}
|
||||
|
||||
// Multiple triggers can use the same datapoint.
|
||||
for _, trigger := range triggers {
|
||||
var out any
|
||||
out, err = trigger.Run()
|
||||
@ -204,23 +207,77 @@ func entryDatapoint(w http.ResponseWriter, r *http.Request, sess *session.T) { /
|
||||
}
|
||||
logger.Debug("entry", "datapoint", dpoint, "value", value, "trigger", trigger, "result", out)
|
||||
|
||||
var problemID int
|
||||
switch v := out.(type) {
|
||||
case bool:
|
||||
// Trigger returning true - a problem occurred
|
||||
if v {
|
||||
err = ProblemStart(trigger)
|
||||
problemID, err = ProblemStart(trigger)
|
||||
logger.Info("FOO", "problemID", problemID, "err==nil", err == nil)
|
||||
if err != nil {
|
||||
err = werr.Wrap(err).Log()
|
||||
logger.Error("entry", "error", err)
|
||||
}
|
||||
|
||||
} else {
|
||||
err = ProblemClose(trigger)
|
||||
// A problem didn't occur.
|
||||
problemID, err = ProblemClose(trigger)
|
||||
logger.Info("FOO", "problemID", problemID, "err==nil", err == nil)
|
||||
if err != nil {
|
||||
err = werr.Wrap(err).Log()
|
||||
logger.Error("entry", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Has a change in problem state happened?
|
||||
if problemID == 0 && err == nil {
|
||||
logger.Debug("notification", "trigger", trigger.ID, "state", "no change")
|
||||
continue
|
||||
} else {
|
||||
logger.Debug("notification", "trigger", trigger.ID, "state", "change")
|
||||
}
|
||||
|
||||
err = notificationManager.Send(problemID, []byte(trigger.Name), func(notificationService *notification.Service, err error) {
|
||||
logger.Info(
|
||||
"notification",
|
||||
"service", (*notificationService).GetType(),
|
||||
"problemID", problemID,
|
||||
"prio", (*notificationService).GetPrio(),
|
||||
"ok", true,
|
||||
)
|
||||
|
||||
var errBody any
|
||||
if err != nil {
|
||||
errBody, _ = json.Marshal(err)
|
||||
} else {
|
||||
errBody = nil
|
||||
}
|
||||
_, err = service.Db.Conn.Exec(
|
||||
`
|
||||
INSERT INTO notification_send(notification_id, problem_id, uuid, ok, error)
|
||||
SELECT
|
||||
id, $3, '', $4, $5
|
||||
FROM notification
|
||||
WHERE
|
||||
service=$1 AND
|
||||
prio=$2
|
||||
`,
|
||||
(*notificationService).GetType(),
|
||||
(*notificationService).GetPrio(),
|
||||
problemID,
|
||||
err == nil,
|
||||
errBody,
|
||||
)
|
||||
if err != nil {
|
||||
err = werr.Wrap(err).Log()
|
||||
logger.Error("entry", "error", err)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
err = werr.Wrap(err).Log()
|
||||
logger.Error("notification", "error", err)
|
||||
}
|
||||
|
||||
default:
|
||||
err := fmt.Errorf(`Expression for trigger %s not returning bool (%T)`, trigger.Name, v)
|
||||
logger.Info("entry", "error", err)
|
||||
@ -519,6 +576,43 @@ func pageDatapointDelete(w http.ResponseWriter, r *http.Request, _ *session.T) {
|
||||
w.Header().Add("Location", "/datapoints")
|
||||
w.WriteHeader(302)
|
||||
} // }}}
|
||||
func pageDatapointValues(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
|
||||
idStr := r.PathValue("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
httpError(w, werr.Wrap(err).Log())
|
||||
return
|
||||
}
|
||||
|
||||
var datapoint Datapoint
|
||||
datapoint, err = DatapointRetrieve(id, "")
|
||||
if err != nil {
|
||||
httpError(w, werr.Wrap(err).Log())
|
||||
return
|
||||
}
|
||||
|
||||
var values []DatapointValue
|
||||
values, err = DatapointValues(id)
|
||||
if err != nil {
|
||||
httpError(w, werr.Wrap(err).Log())
|
||||
return
|
||||
}
|
||||
|
||||
page := Page{
|
||||
LAYOUT: "main",
|
||||
PAGE: "datapoint_values",
|
||||
MENU: "datapoints",
|
||||
Icon: "datapoints",
|
||||
Label: "Values for "+datapoint.Name,
|
||||
}
|
||||
|
||||
page.Data = map[string]any{
|
||||
"Datapoint": datapoint,
|
||||
"Values": values,
|
||||
}
|
||||
page.Render(w)
|
||||
return
|
||||
} // }}}
|
||||
|
||||
func pageTriggers(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{
|
||||
areas, err := TriggersRetrieve()
|
||||
|
@ -44,7 +44,7 @@ func (ntfy *NTFY) GetPrio() int {
|
||||
return ntfy.Prio
|
||||
}
|
||||
|
||||
func (ntfy NTFY) Send(uuid string, msg []byte) (err error) {
|
||||
func (ntfy NTFY) Send(problemID int, msg []byte) (err error) {
|
||||
var req *http.Request
|
||||
var res *http.Response
|
||||
req, err = http.NewRequest("POST", ntfy.URL, bytes.NewReader(msg))
|
||||
@ -53,9 +53,9 @@ func (ntfy NTFY) Send(uuid string, msg []byte) (err error) {
|
||||
return
|
||||
}
|
||||
|
||||
ackURL := fmt.Sprintf("http, OK, %s/notification/ack?uuid=%s", ntfy.AcknowledgeURL, uuid)
|
||||
ackURL := fmt.Sprintf("http, OK, %s/notification/ack?problemID=%d", ntfy.AcknowledgeURL, problemID)
|
||||
req.Header.Add("X-Actions", ackURL)
|
||||
req.Header.Add("X-Priority", "3") // XXX: should be 5
|
||||
req.Header.Add("X-Priority", "4") // XXX: should be 5
|
||||
req.Header.Add("X-Tags", "calendar")
|
||||
|
||||
res, err = http.DefaultClient.Do(req)
|
||||
|
@ -13,7 +13,7 @@ type Service interface {
|
||||
SetLogger(*slog.Logger)
|
||||
GetPrio() int
|
||||
GetType() string
|
||||
Send(string, []byte) error
|
||||
Send(int, []byte) error
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
@ -40,20 +40,22 @@ func (nm *Manager) AddService(service Service) {
|
||||
})
|
||||
}
|
||||
|
||||
func (nm *Manager) Send(uuid string, msg []byte) (err error) {
|
||||
for _, service := range nm.services {
|
||||
func (nm *Manager) Send(problemID int, msg []byte, fn func(*Service, error)) (err error) {
|
||||
for i, service := range nm.services {
|
||||
nm.logger.Info("notification", "service", service.GetType(), "prio", service.GetPrio())
|
||||
if err = service.Send(uuid, msg); err == nil {
|
||||
if err = service.Send(problemID, msg); err == nil {
|
||||
fn(&nm.services[i], nil)
|
||||
break
|
||||
} else {
|
||||
data := struct {
|
||||
UUID string
|
||||
ProblemID int
|
||||
Msg []byte
|
||||
}{
|
||||
uuid,
|
||||
problemID,
|
||||
msg,
|
||||
}
|
||||
werr.Wrap(err).WithData(data).Log()
|
||||
fn(&nm.services[i], err)
|
||||
}
|
||||
}
|
||||
|
||||
|
20
notification_log.go
Normal file
20
notification_log.go
Normal file
@ -0,0 +1,20 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
// Internal
|
||||
"smon/notification"
|
||||
// Standard
|
||||
)
|
||||
|
||||
func notificationLog(notificationService *notification.Service, problemID int, err error) {
|
||||
if err == nil {
|
||||
logger.Info("notification", "service", (*notificationService).GetType(), "problemID", problemID, "prio", (*notificationService).GetPrio(), "ok", true)
|
||||
service.Db.Conn.Query(
|
||||
`
|
||||
INSERT INTO notification_send()
|
||||
`,
|
||||
)
|
||||
} else {
|
||||
logger.Error("notification", "service", (*notificationService).GetType(), "problemID", problemID, "prio", (*notificationService).GetPrio(), "ok", false, "error", err)
|
||||
}
|
||||
}
|
16
problem.go
16
problem.go
@ -66,7 +66,7 @@ func ProblemsRetrieve() (problems []Problem, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
func ProblemStart(trigger Trigger) (err error) {
|
||||
func ProblemStart(trigger Trigger) (problemID int, err error) {
|
||||
row := service.Db.Conn.QueryRow(`
|
||||
SELECT COUNT(id)
|
||||
FROM problem
|
||||
@ -86,7 +86,8 @@ func ProblemStart(trigger Trigger) (err error) {
|
||||
|
||||
// Open up a new problem if no open exists.
|
||||
if openProblems == 0 {
|
||||
_, err = service.Db.Conn.Exec(`INSERT INTO problem(trigger_id) VALUES($1)`, trigger.ID)
|
||||
row = service.Db.Conn.QueryRow(`INSERT INTO problem(trigger_id) VALUES($1) RETURNING id`, trigger.ID)
|
||||
err = row.Scan(&problemID)
|
||||
if err != nil {
|
||||
err = we.Wrap(err).WithData(trigger)
|
||||
}
|
||||
@ -94,8 +95,15 @@ func ProblemStart(trigger Trigger) (err error) {
|
||||
return
|
||||
}
|
||||
|
||||
func ProblemClose(trigger Trigger) (err error) {
|
||||
_, err = service.Db.Conn.Exec(`UPDATE problem SET "end"=NOW() WHERE trigger_id=$1 AND "end" IS NULL`, trigger.ID)
|
||||
func ProblemClose(trigger Trigger) (problemID int, err error) {
|
||||
row := service.Db.Conn.QueryRow(`UPDATE problem SET "end"=NOW() WHERE trigger_id=$1 AND "end" IS NULL RETURNING id`, trigger.ID)
|
||||
err = row.Scan(&problemID)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
err = nil
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
err = we.Wrap(err).WithData(trigger)
|
||||
return
|
||||
|
13
sql/00012.sql
Normal file
13
sql/00012.sql
Normal file
@ -0,0 +1,13 @@
|
||||
CREATE TABLE public.notification_send (
|
||||
id serial NOT NULL,
|
||||
notification_id int4 NOT NULL,
|
||||
"uuid" char(36) NOT NULL,
|
||||
send timestamptz DEFAULT now() NOT NULL,
|
||||
ok bool NOT NULL,
|
||||
error jsonb NULL,
|
||||
acknowledged bool DEFAULT false NOT NULL,
|
||||
problem_id int8 NOT NULL,
|
||||
CONSTRAINT notification_send_pk PRIMARY KEY (id),
|
||||
CONSTRAINT notification_send_notification_fk FOREIGN KEY (notification_id) REFERENCES public.notification(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT notification_send_problem_fk FOREIGN KEY (problem_id) REFERENCES public.problem(id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
@ -109,6 +109,17 @@ label {
|
||||
#datapoints div {
|
||||
white-space: nowrap;
|
||||
}
|
||||
#datapoints .icons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
#values {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, min-content);
|
||||
gap: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.widgets {
|
||||
display: grid;
|
||||
grid-template-columns: min-content 1fr;
|
||||
|
@ -101,7 +101,6 @@ label {
|
||||
display: grid;
|
||||
grid-template-areas: "menu content";
|
||||
grid-template-columns: 64px 1fr;
|
||||
grid-template-rows: 100% 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
#menu {
|
||||
|
67
static/images/values.svg
Normal file
67
static/images/values.svg
Normal file
@ -0,0 +1,67 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="18.500002"
|
||||
height="15"
|
||||
viewBox="0 0 4.8947921 3.9687501"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:version="1.3.2 (1:1.3.2+202311252150+091e20ef0f)"
|
||||
sodipodi:docname="points.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="1"
|
||||
inkscape:cx="-33"
|
||||
inkscape:cy="-79"
|
||||
inkscape:document-units="px"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
units="px"
|
||||
inkscape:window-width="1093"
|
||||
inkscape:window-height="1404"
|
||||
inkscape:window-x="1463"
|
||||
inkscape:window-y="16"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:showpageshadow="true"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d6d6d6"
|
||||
showborder="true" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-81.359375,-211.79896)">
|
||||
<title
|
||||
id="title1">format-list-bulleted</title>
|
||||
<path
|
||||
d="m 82.55,211.93125 h 3.704167 v 0.52917 H 82.55 v -0.52917 m 0,2.11667 v -0.52917 h 3.704167 v 0.52917 H 82.55 m -0.79375,-2.24896 a 0.396875,0.396875 0 0 1 0.396875,0.39687 0.396875,0.396875 0 0 1 -0.396875,0.39688 0.396875,0.396875 0 0 1 -0.396875,-0.39688 0.396875,0.396875 0 0 1 0.396875,-0.39687 m 0,1.5875 a 0.396875,0.396875 0 0 1 0.396875,0.39687 0.396875,0.396875 0 0 1 -0.396875,0.39688 0.396875,0.396875 0 0 1 -0.396875,-0.39688 0.396875,0.396875 0 0 1 0.396875,-0.39687 m 0.79375,2.24896 v -0.52917 h 3.704167 v 0.52917 H 82.55 m -0.79375,-0.66146 a 0.396875,0.396875 0 0 1 0.396875,0.39687 0.396875,0.396875 0 0 1 -0.396875,0.39688 0.396875,0.396875 0 0 1 -0.396875,-0.39688 0.396875,0.396875 0 0 1 0.396875,-0.39687 z"
|
||||
id="path1"
|
||||
style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:#fb4934;fill-opacity:1;stroke-width:0.384845;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;-inkscape-stroke:none;stop-color:#000000;stop-opacity:1" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 2.8 KiB |
@ -13,6 +13,19 @@
|
||||
div {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.icons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
#values {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, min-content);
|
||||
gap: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.widgets {
|
||||
|
@ -4,8 +4,6 @@
|
||||
display: grid;
|
||||
grid-template-areas: "menu content";
|
||||
grid-template-columns: 64px 1fr;
|
||||
grid-template-rows:
|
||||
100% 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
|
13
views/pages/datapoint_values.gotmpl
Normal file
13
views/pages/datapoint_values.gotmpl
Normal file
@ -0,0 +1,13 @@
|
||||
{{ define "page" }}
|
||||
{{ $version := .VERSION }}
|
||||
<link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/datapoints.css">
|
||||
|
||||
{{ block "page_label" . }}{{end}}
|
||||
|
||||
<div id="values">
|
||||
{{ range .Data.Values }}
|
||||
<div class="value">{{ format_time .Ts }}</div>
|
||||
<div class="value">{{ .Value }}</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
@ -23,7 +23,10 @@
|
||||
{{ else }}
|
||||
<div class="value">{{ .LastDatapointValue.Value }}</div>
|
||||
{{ end }}
|
||||
<div class="delete"><a href="/datapoint/delete/{{ .ID }}" onclick="confirm(`Are you sure you want to delete '{{ .Name }}'?`)"><img src="/images/{{ $version }}/delete.svg"></a></div>
|
||||
{{ end }}
|
||||
<div class="icons">
|
||||
<div class="values"><a href="/datapoint/values/{{ .ID }}"><img src="/images/{{ $version }}/values.svg"></a></div>
|
||||
<div class="delete"><a href="/datapoint/delete/{{ .ID }}" onclick="confirm(`Are you sure you want to delete '{{ .Name }}'?`)"><img src="/images/{{ $version }}/delete.svg"></a></div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
Loading…
Reference in New Issue
Block a user