Merge pull request #107 from ssb-ngi-pointer/style-dashboard

Style dashboard
This commit is contained in:
André Staltz 2021-03-30 11:50:44 +03:00 committed by GitHub
commit b3e46f2e8d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 392 additions and 26 deletions

1
go.sum
View File

@ -546,6 +546,7 @@ golang.org/x/net v0.0.0-20191116160921-f9c825593386 h1:ktbWvQrW08Txdxno1PiDpSxPX
golang.org/x/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201006153459-a7d1128ccaa0/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201026091529-146b70c837a4 h1:awiuzyrRjJDb+OXi9ceHO3SDxVoN3JER57mhtqkdQBs=
golang.org/x/net v0.0.0-20201026091529-146b70c837a4/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=

View File

@ -65,6 +65,9 @@ type MembersService interface {
// List returns a list of all the members.
List(context.Context) ([]Member, error)
// Count returns the total number of members.
Count(context.Context) (uint, error)
// RemoveFeed removes the feed from the list.
RemoveFeed(context.Context, refs.FeedRef) error
@ -95,6 +98,9 @@ type DeniedKeysService interface {
// List returns a list of all the feeds.
List(context.Context) ([]ListEntry, error)
// Count returns the total number of denied keys.
Count(context.Context) (uint, error)
// RemoveFeed removes the feed from the list.
RemoveFeed(context.Context, refs.FeedRef) error
@ -141,6 +147,9 @@ type InvitesService interface {
// List returns a list of all the valid invites
List(ctx context.Context) ([]Invite, error)
// Count returns the total number of invites.
Count(context.Context) (uint, error)
// Revoke removes a active invite and invalidates it for future use.
Revoke(ctx context.Context, id int64) error
}

View File

@ -23,6 +23,19 @@ type FakeDeniedKeysService struct {
addReturnsOnCall map[int]struct {
result1 error
}
CountStub func(context.Context) (uint, error)
countMutex sync.RWMutex
countArgsForCall []struct {
arg1 context.Context
}
countReturns struct {
result1 uint
result2 error
}
countReturnsOnCall map[int]struct {
result1 uint
result2 error
}
GetByIDStub func(context.Context, int64) (roomdb.ListEntry, error)
getByIDMutex sync.RWMutex
getByIDArgsForCall []struct {
@ -165,6 +178,70 @@ func (fake *FakeDeniedKeysService) AddReturnsOnCall(i int, result1 error) {
}{result1}
}
func (fake *FakeDeniedKeysService) Count(arg1 context.Context) (uint, error) {
fake.countMutex.Lock()
ret, specificReturn := fake.countReturnsOnCall[len(fake.countArgsForCall)]
fake.countArgsForCall = append(fake.countArgsForCall, struct {
arg1 context.Context
}{arg1})
stub := fake.CountStub
fakeReturns := fake.countReturns
fake.recordInvocation("Count", []interface{}{arg1})
fake.countMutex.Unlock()
if stub != nil {
return stub(arg1)
}
if specificReturn {
return ret.result1, ret.result2
}
return fakeReturns.result1, fakeReturns.result2
}
func (fake *FakeDeniedKeysService) CountCallCount() int {
fake.countMutex.RLock()
defer fake.countMutex.RUnlock()
return len(fake.countArgsForCall)
}
func (fake *FakeDeniedKeysService) CountCalls(stub func(context.Context) (uint, error)) {
fake.countMutex.Lock()
defer fake.countMutex.Unlock()
fake.CountStub = stub
}
func (fake *FakeDeniedKeysService) CountArgsForCall(i int) context.Context {
fake.countMutex.RLock()
defer fake.countMutex.RUnlock()
argsForCall := fake.countArgsForCall[i]
return argsForCall.arg1
}
func (fake *FakeDeniedKeysService) CountReturns(result1 uint, result2 error) {
fake.countMutex.Lock()
defer fake.countMutex.Unlock()
fake.CountStub = nil
fake.countReturns = struct {
result1 uint
result2 error
}{result1, result2}
}
func (fake *FakeDeniedKeysService) CountReturnsOnCall(i int, result1 uint, result2 error) {
fake.countMutex.Lock()
defer fake.countMutex.Unlock()
fake.CountStub = nil
if fake.countReturnsOnCall == nil {
fake.countReturnsOnCall = make(map[int]struct {
result1 uint
result2 error
})
}
fake.countReturnsOnCall[i] = struct {
result1 uint
result2 error
}{result1, result2}
}
func (fake *FakeDeniedKeysService) GetByID(arg1 context.Context, arg2 int64) (roomdb.ListEntry, error) {
fake.getByIDMutex.Lock()
ret, specificReturn := fake.getByIDReturnsOnCall[len(fake.getByIDArgsForCall)]
@ -547,6 +624,8 @@ func (fake *FakeDeniedKeysService) Invocations() map[string][][]interface{} {
defer fake.invocationsMutex.RUnlock()
fake.addMutex.RLock()
defer fake.addMutex.RUnlock()
fake.countMutex.RLock()
defer fake.countMutex.RUnlock()
fake.getByIDMutex.RLock()
defer fake.getByIDMutex.RUnlock()
fake.hasFeedMutex.RLock()

View File

@ -25,6 +25,19 @@ type FakeInvitesService struct {
result1 roomdb.Invite
result2 error
}
CountStub func(context.Context) (uint, error)
countMutex sync.RWMutex
countArgsForCall []struct {
arg1 context.Context
}
countReturns struct {
result1 uint
result2 error
}
countReturnsOnCall map[int]struct {
result1 uint
result2 error
}
CreateStub func(context.Context, int64) (string, error)
createMutex sync.RWMutex
createArgsForCall []struct {
@ -162,6 +175,70 @@ func (fake *FakeInvitesService) ConsumeReturnsOnCall(i int, result1 roomdb.Invit
}{result1, result2}
}
func (fake *FakeInvitesService) Count(arg1 context.Context) (uint, error) {
fake.countMutex.Lock()
ret, specificReturn := fake.countReturnsOnCall[len(fake.countArgsForCall)]
fake.countArgsForCall = append(fake.countArgsForCall, struct {
arg1 context.Context
}{arg1})
stub := fake.CountStub
fakeReturns := fake.countReturns
fake.recordInvocation("Count", []interface{}{arg1})
fake.countMutex.Unlock()
if stub != nil {
return stub(arg1)
}
if specificReturn {
return ret.result1, ret.result2
}
return fakeReturns.result1, fakeReturns.result2
}
func (fake *FakeInvitesService) CountCallCount() int {
fake.countMutex.RLock()
defer fake.countMutex.RUnlock()
return len(fake.countArgsForCall)
}
func (fake *FakeInvitesService) CountCalls(stub func(context.Context) (uint, error)) {
fake.countMutex.Lock()
defer fake.countMutex.Unlock()
fake.CountStub = stub
}
func (fake *FakeInvitesService) CountArgsForCall(i int) context.Context {
fake.countMutex.RLock()
defer fake.countMutex.RUnlock()
argsForCall := fake.countArgsForCall[i]
return argsForCall.arg1
}
func (fake *FakeInvitesService) CountReturns(result1 uint, result2 error) {
fake.countMutex.Lock()
defer fake.countMutex.Unlock()
fake.CountStub = nil
fake.countReturns = struct {
result1 uint
result2 error
}{result1, result2}
}
func (fake *FakeInvitesService) CountReturnsOnCall(i int, result1 uint, result2 error) {
fake.countMutex.Lock()
defer fake.countMutex.Unlock()
fake.CountStub = nil
if fake.countReturnsOnCall == nil {
fake.countReturnsOnCall = make(map[int]struct {
result1 uint
result2 error
})
}
fake.countReturnsOnCall[i] = struct {
result1 uint
result2 error
}{result1, result2}
}
func (fake *FakeInvitesService) Create(arg1 context.Context, arg2 int64) (string, error) {
fake.createMutex.Lock()
ret, specificReturn := fake.createReturnsOnCall[len(fake.createArgsForCall)]
@ -488,6 +565,8 @@ func (fake *FakeInvitesService) Invocations() map[string][][]interface{} {
defer fake.invocationsMutex.RUnlock()
fake.consumeMutex.RLock()
defer fake.consumeMutex.RUnlock()
fake.countMutex.RLock()
defer fake.countMutex.RUnlock()
fake.createMutex.RLock()
defer fake.createMutex.RUnlock()
fake.getByIDMutex.RLock()

View File

@ -25,6 +25,19 @@ type FakeMembersService struct {
result1 int64
result2 error
}
CountStub func(context.Context) (uint, error)
countMutex sync.RWMutex
countArgsForCall []struct {
arg1 context.Context
}
countReturns struct {
result1 uint
result2 error
}
countReturnsOnCall map[int]struct {
result1 uint
result2 error
}
GetByFeedStub func(context.Context, refs.FeedRef) (roomdb.Member, error)
getByFeedMutex sync.RWMutex
getByFeedArgsForCall []struct {
@ -173,6 +186,70 @@ func (fake *FakeMembersService) AddReturnsOnCall(i int, result1 int64, result2 e
}{result1, result2}
}
func (fake *FakeMembersService) Count(arg1 context.Context) (uint, error) {
fake.countMutex.Lock()
ret, specificReturn := fake.countReturnsOnCall[len(fake.countArgsForCall)]
fake.countArgsForCall = append(fake.countArgsForCall, struct {
arg1 context.Context
}{arg1})
stub := fake.CountStub
fakeReturns := fake.countReturns
fake.recordInvocation("Count", []interface{}{arg1})
fake.countMutex.Unlock()
if stub != nil {
return stub(arg1)
}
if specificReturn {
return ret.result1, ret.result2
}
return fakeReturns.result1, fakeReturns.result2
}
func (fake *FakeMembersService) CountCallCount() int {
fake.countMutex.RLock()
defer fake.countMutex.RUnlock()
return len(fake.countArgsForCall)
}
func (fake *FakeMembersService) CountCalls(stub func(context.Context) (uint, error)) {
fake.countMutex.Lock()
defer fake.countMutex.Unlock()
fake.CountStub = stub
}
func (fake *FakeMembersService) CountArgsForCall(i int) context.Context {
fake.countMutex.RLock()
defer fake.countMutex.RUnlock()
argsForCall := fake.countArgsForCall[i]
return argsForCall.arg1
}
func (fake *FakeMembersService) CountReturns(result1 uint, result2 error) {
fake.countMutex.Lock()
defer fake.countMutex.Unlock()
fake.CountStub = nil
fake.countReturns = struct {
result1 uint
result2 error
}{result1, result2}
}
func (fake *FakeMembersService) CountReturnsOnCall(i int, result1 uint, result2 error) {
fake.countMutex.Lock()
defer fake.countMutex.Unlock()
fake.CountStub = nil
if fake.countReturnsOnCall == nil {
fake.countReturnsOnCall = make(map[int]struct {
result1 uint
result2 error
})
}
fake.countReturnsOnCall[i] = struct {
result1 uint
result2 error
}{result1, result2}
}
func (fake *FakeMembersService) GetByFeed(arg1 context.Context, arg2 refs.FeedRef) (roomdb.Member, error) {
fake.getByFeedMutex.Lock()
ret, specificReturn := fake.getByFeedReturnsOnCall[len(fake.getByFeedArgsForCall)]
@ -559,6 +636,8 @@ func (fake *FakeMembersService) Invocations() map[string][][]interface{} {
defer fake.invocationsMutex.RUnlock()
fake.addMutex.RLock()
defer fake.addMutex.RUnlock()
fake.countMutex.RLock()
defer fake.countMutex.RUnlock()
fake.getByFeedMutex.RLock()
defer fake.getByFeedMutex.RUnlock()
fake.getByIDMutex.RLock()

View File

@ -104,6 +104,14 @@ func (dk DeniedKeys) List(ctx context.Context) ([]roomdb.ListEntry, error) {
return lst, nil
}
func (dk DeniedKeys) Count(ctx context.Context) (uint, error) {
count, err := models.DeniedKeys().Count(ctx, dk.db)
if err != nil {
return 0, err
}
return uint(count), nil
}
// RemoveFeed removes the feed from the list.
func (dk DeniedKeys) RemoveFeed(ctx context.Context, r refs.FeedRef) error {
entry, err := models.DeniedKeys(qm.Where("pub_key = ?", r.Ref())).One(ctx, dk.db)

View File

@ -226,6 +226,14 @@ func (i Invites) List(ctx context.Context) ([]roomdb.Invite, error) {
return invs, nil
}
func (i Invites) Count(ctx context.Context) (uint, error) {
count, err := models.Members().Count(ctx, i.db)
if err != nil {
return 0, err
}
return uint(count), nil
}
// Revoke removes a active invite and invalidates it for future use.
func (i Invites) Revoke(ctx context.Context, id int64) error {
return transact(i.db, func(tx *sql.Tx) error {

View File

@ -97,6 +97,14 @@ func (m Members) List(ctx context.Context) ([]roomdb.Member, error) {
return members, nil
}
func (m Members) Count(ctx context.Context) (uint, error) {
count, err := models.Members().Count(ctx, m.db)
if err != nil {
return 0, err
}
return uint(count), nil
}
// RemoveFeed removes the feed from the list.
func (m Members) RemoveFeed(ctx context.Context, r refs.FeedRef) error {
entry, err := models.Members(qm.Where("pub_key = ?", r.Ref())).One(ctx, m.db)

View File

@ -63,11 +63,27 @@ func Handler(
// TODO: configure 404 handler
mux.HandleFunc("/dashboard", r.HTML("admin/dashboard.tmpl", func(rw http.ResponseWriter, req *http.Request) (interface{}, error) {
lst := roomState.List()
onlineRefs := roomState.List()
onlineCount := len(onlineRefs)
memberCount, err := dbs.Members.Count(req.Context())
if err != nil {
return nil, fmt.Errorf("failed to count members: %w", err)
}
inviteCount, err := dbs.Invites.Count(req.Context())
if err != nil {
return nil, fmt.Errorf("failed to count invites: %w", err)
}
deniedCount, err := dbs.DeniedKeys.Count(req.Context())
if err != nil {
return nil, fmt.Errorf("failed to count denied keys: %w", err)
}
return struct {
Clients []string
Count int
}{lst, len(lst)}, nil
OnlineRefs []string
OnlineCount int
MemberCount uint
InviteCount uint
DeniedCount uint
}{onlineRefs, onlineCount, memberCount, inviteCount, deniedCount}, nil
}))
mux.HandleFunc("/menu", r.HTML("admin/menu.tmpl", func(w http.ResponseWriter, req *http.Request) (interface{}, error) {
return map[string]interface{}{}, nil

View File

@ -1,27 +1,38 @@
package admin
import (
"bytes"
"net/http"
"testing"
"github.com/ssb-ngi-pointer/go-ssb-room/web/router"
"github.com/ssb-ngi-pointer/go-ssb-room/web/webassert"
"github.com/stretchr/testify/assert"
refs "go.mindeco.de/ssb-refs"
)
func TestDashoard(t *testing.T) {
ts := newSession(t)
a := assert.New(t)
testRef := refs.FeedRef{Algo: "test", ID: bytes.Repeat([]byte{0}, 16)}
ts.RoomState.AddEndpoint(testRef, nil) // 1 online
ts.MembersDB.CountReturns(4, nil) // 4 members
ts.InvitesDB.CountReturns(3, nil) // 3 invites
ts.DeniedKeysDB.CountReturns(2, nil) // 2 banned
url, err := ts.Router.Get(router.AdminDashboard).URL()
a.Nil(err)
html, resp := ts.Client.GetHTML(url.String())
a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code")
a.Equal("1", html.Find("#online-count").Text())
a.Equal("4", html.Find("#member-count").Text())
a.Equal("3", html.Find("#invite-count").Text())
a.Equal("2", html.Find("#denied-count").Text())
webassert.Localized(t, html, []webassert.LocalizedElement{
{"#welcome", "AdminDashboardWelcome"},
{"title", "AdminDashboardTitle"},
{"#roomCount", "AdminRoomCountPlural"},
})
}

View File

@ -154,7 +154,6 @@ func TestFallbackAuth(t *testing.T) {
}
webassert.Localized(t, html, []webassert.LocalizedElement{
{"#welcome", "AdminDashboardWelcome"},
{"title", "AdminDashboardTitle"},
})
@ -166,9 +165,7 @@ func TestFallbackAuth(t *testing.T) {
t.Log(html.Find("body").Text())
}
webassert.Localized(t, html, []webassert.LocalizedElement{
{"#welcome", "AdminDashboardWelcome"},
{"title", "AdminDashboardTitle"},
{"#roomCount", "AdminRoomCountSingular"},
})
testRef2 := refs.FeedRef{Algo: "test", ID: bytes.Repeat([]byte{1}, 16)}
@ -178,9 +175,7 @@ func TestFallbackAuth(t *testing.T) {
a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code")
webassert.Localized(t, html, []webassert.LocalizedElement{
{"#welcome", "AdminDashboardWelcome"},
{"title", "AdminDashboardTitle"},
{"#roomCount", "AdminRoomCountPlural"},
})
}
@ -375,7 +370,6 @@ func TestAuthWithSSBClientInitHasClient(t *testing.T) {
}
webassert.Localized(t, html, []webassert.LocalizedElement{
{"#welcome", "AdminDashboardWelcome"},
{"title", "AdminDashboardTitle"},
})
}
@ -502,7 +496,6 @@ func TestAuthWithSSBServerInitHappyPath(t *testing.T) {
}
webassert.Localized(t, html, []webassert.LocalizedElement{
{"#welcome", "AdminDashboardWelcome"},
{"title", "AdminDashboardTitle"},
})
}

View File

@ -41,6 +41,7 @@ type testSession struct {
AliasesDB *mockdb.FakeAliasesService
MembersDB *mockdb.FakeMembersService
InvitesDB *mockdb.FakeInvitesService
DeniedKeysDB *mockdb.FakeDeniedKeysService
PinnedDB *mockdb.FakePinnedNoticesService
NoticeDB *mockdb.FakeNoticesService
@ -76,6 +77,7 @@ func setup(t *testing.T) *testSession {
ts.AliasesDB = new(mockdb.FakeAliasesService)
ts.MembersDB = new(mockdb.FakeMembersService)
ts.InvitesDB = new(mockdb.FakeInvitesService)
ts.DeniedKeysDB = new(mockdb.FakeDeniedKeysService)
ts.PinnedDB = new(mockdb.FakePinnedNoticesService)
defaultNotice := &roomdb.Notice{
Title: "Default Notice Title",
@ -118,6 +120,7 @@ func setup(t *testing.T) *testSession {
AuthWithSSB: ts.AuthWithSSB,
Members: ts.MembersDB,
Invites: ts.InvitesDB,
DeniedKeys: ts.DeniedKeysDB,
Notices: ts.NoticeDB,
PinnedNotices: ts.PinnedDB,
},

View File

@ -31,8 +31,8 @@ AuthFallbackTitle = "Password sign-in"
AuthFallbackWelcome = "Signing in with username and password is only possible if the administrator has given you one, because we do not support user registration."
AuthFallbackInstruct = "This method is an acceptable fallback, if you have a username and password."
AdminDashboardTitle = "Dashboard"
AdminDashboardWelcome = "Welcome to your dashboard"
AdminDashboardTitle = "Room Admin Dashboard"
AdminAliasesTitle = "Aliases"
AdminAliasesWelcome = "Here you can see and revoke the registered aliases of this room."

View File

@ -1,16 +1,88 @@
{{ define "title" }}{{i18n "AdminDashboardTitle"}}{{ end }}
{{ define "content" }}
<div class="page-header">
<h1 id="welcome">{{i18n "AdminDashboardWelcome"}}</h1>
</div>
<div class="row">
<div class="text-xs">
<p class="text-xl" id="roomCount">{{i18npl "AdminRoomCount" .Count}}</p>
<ul>
{{range .Clients}}
<li>{{.}}</li>
{{end}}
</ul>
<h1
class="text-3xl tracking-tight font-black text-black mt-2 mb-0"
>{{i18n "AdminDashboardTitle"}}</h1>
<div class="flex flex-col-reverse sm:flex-row justify-start items-stretch ">
<div class="sm:mr-4 mt-6 py-6 px-4 border-gray-200 border-2 rounded-3xl flex flex-col justify-start items-start">
<div class="grid grid-rows-2 grid-flow-col gap-x-4 gap-y-0">
{{if gt .OnlineCount 0}}
<div class="row-span-2 w-14 h-14 bg-green-50 rounded flex flex-col justify-center items-center">
<div class="w-3 h-3 bg-green-500 rounded-full relative">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span>
</div>
</div>
{{else}}
<div class="row-span-2 w-14 h-14 bg-gray-100 rounded flex flex-col justify-center items-center">
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
</div>
{{end}}
<div
id="online-count"
class="col-span-2 font-black text-black text-xl"
>{{.OnlineCount}}</div>
<div class="col-span-2 text-gray-500">Online</iv>
</div>
</div>
</div> <!-- /row -->
<div class="sm:mr-4 mt-6 py-6 px-4 border-gray-200 border-2 rounded-3xl flex flex-col justify-start items-start">
<div class="grid grid-rows-2 grid-flow-col gap-x-4 gap-y-0">
<div class="row-span-2 w-14 h-14 bg-purple-50 rounded flex flex-col justify-center items-center">
<svg class="text-purple-600 w-8 h-8" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,6A2,2 0 0,0 10,8A2,2 0 0,0 12,10A2,2 0 0,0 14,8A2,2 0 0,0 12,6M12,13C14.67,13 20,14.33 20,17V20H4V17C4,14.33 9.33,13 12,13M12,14.9C9.03,14.9 5.9,16.36 5.9,17V18.1H18.1V17C18.1,16.36 14.97,14.9 12,14.9Z" />
</svg>
</div>
<div
id="member-count"
class="col-span-2 font-black text-black text-xl"
>{{.MemberCount}}</div>
<div class="col-span-2 text-gray-500">{{i18n "AdminMembersTitle"}}</div>
</div>
</div>
<div class="sm:mr-4 mt-6 py-6 px-4 border-gray-200 border-2 rounded-3xl flex flex-col justify-start items-start">
<div class="grid grid-rows-2 grid-flow-col gap-x-4 gap-y-0">
<div class="row-span-2 w-14 h-14 bg-purple-50 rounded flex flex-col justify-center items-center">
<svg class="text-purple-600 w-8 h-8" viewBox="0 0 24 24">
<path fill="currentColor" d="M15,4A4,4 0 0,0 11,8A4,4 0 0,0 15,12A4,4 0 0,0 19,8A4,4 0 0,0 15,4M15,5.9C16.16,5.9 17.1,6.84 17.1,8C17.1,9.16 16.16,10.1 15,10.1A2.1,2.1 0 0,1 12.9,8A2.1,2.1 0 0,1 15,5.9M4,7V10H1V12H4V15H6V12H9V10H6V7H4M15,13C12.33,13 7,14.33 7,17V20H23V17C23,14.33 17.67,13 15,13M15,14.9C17.97,14.9 21.1,16.36 21.1,17V18.1H8.9V17C8.9,16.36 12,14.9 15,14.9Z" />
</svg>
</div>
<div
id="invite-count"
class="col-span-2 font-black text-black text-xl"
>{{.InviteCount}}</div>
<div class="col-span-2 text-gray-500">{{i18n "AdminInvitesTitle"}}</div>
</div>
</div>
<div class="sm:mr-4 mt-6 py-6 px-4 border-gray-200 border-2 rounded-3xl flex flex-col justify-start items-start">
<div class="grid grid-rows-2 grid-flow-col gap-x-4 gap-y-0">
<div class="row-span-2 w-14 h-14 bg-red-50 rounded flex flex-col justify-center items-center">
<svg class="text-red-600 w-6 h-6" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,0A12,12 0 0,1 24,12A12,12 0 0,1 12,24A12,12 0 0,1 0,12A12,12 0 0,1 12,0M12,2A10,10 0 0,0 2,12C2,14.4 2.85,16.6 4.26,18.33L18.33,4.26C16.6,2.85 14.4,2 12,2M12,22A10,10 0 0,0 22,12C22,9.6 21.15,7.4 19.74,5.67L5.67,19.74C7.4,21.15 9.6,22 12,22Z" />
</svg>
</div>
<div
id="denied-count"
class="col-span-2 font-black text-black text-xl"
>{{.DeniedCount}}</div>
<div class="col-span-2 text-gray-500">{{i18n "AdminDeniedKeysTitle"}}</div>
</div>
</div>
</div>
<div class="mb-8">
{{if gt .OnlineCount 0}}
<div class="ml-11 h-8 w-0.5 bg-gray-200"></div>
{{end}}
{{range .OnlineRefs}}
<div class="ml-11 h-8 w-0.5 bg-gray-200"></div>
<div class="ml-11 relative h-3">
<div class="absolute inline-flex w-3 h-3 bg-green-500 rounded-full -left-1 -ml-px"></div>
<div class="absolute w-44 sm:w-auto -top-1.5 ml-5 pl-1 font-mono truncate flex-auto text-gray-700">{{.}}</div>
</div>
{{end}}
</div>
{{end}}