mirror of
1
Fork 0

feat(ui): add quota overview (#6602)

Add UI to the quota feature to see what quotas applies to you and if you're exceeding any quota, it's designed to be a general size overview although it's exclusively filled with quota features for now. There's also no UI to see what item is actually taking in the most size. Purely an quota overview.

Screenshots:
![](https://codeberg.org/attachments/9f7480f2-4c31-4d70-8aec-61db79282a1e)
![](https://codeberg.org/attachments/0bd45bf3-28c5-47bf-8fff-c4ae9f38cb28)

With inspiration from concept by 0ko:
![](https://codeberg.org/attachments/b8154a52-6fba-42fc-a4a8-b3ab1527fb33)

Co-authored-by: Otto Richter <git@otto.splvs.net>
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/6602
Reviewed-by: Otto <otto@codeberg.org>
Co-authored-by: Gusted <postmaster@gusted.xyz>
Co-committed-by: Gusted <postmaster@gusted.xyz>
This commit is contained in:
Gusted 2025-02-26 14:36:53 +00:00 committed by 0ko
parent 6dad457552
commit 77a1af5ab8
24 changed files with 348 additions and 33 deletions

View File

@ -7,7 +7,7 @@ import (
"code.gitea.io/gitea/modules/setting"
)
func EvaluateDefault(used Used, forSubject LimitSubject) bool {
func EvaluateDefault(used Used, forSubject LimitSubject) (bool, int64) {
groups := GroupList{
&Group{
Name: "builtin-default-group",

View File

@ -5,6 +5,7 @@ package quota
import (
"context"
"math"
"code.gitea.io/gitea/models/db"
user_model "code.gitea.io/gitea/models/user"
@ -199,15 +200,20 @@ var affectsMap = map[LimitSubject]LimitSubjects{
},
}
func (g *Group) Evaluate(used Used, forSubject LimitSubject) (bool, bool) {
// Evaluate returns whether the size used is acceptable for the topic if a rule
// was found, and returns the smallest limit of all applicable rules or the
// first limit found to be unacceptable for the size used.
func (g *Group) Evaluate(used Used, forSubject LimitSubject) (bool, bool, int64) {
var found bool
foundLimit := int64(math.MaxInt64)
for _, rule := range g.Rules {
ok, has := rule.Evaluate(used, forSubject)
if has {
found = true
if !ok {
return false, true
return false, true, rule.Limit
}
found = true
foundLimit = min(foundLimit, rule.Limit)
}
}
@ -216,32 +222,35 @@ func (g *Group) Evaluate(used Used, forSubject LimitSubject) (bool, bool) {
// subjects below
for _, subject := range affectsMap[forSubject] {
ok, has := g.Evaluate(used, subject)
ok, has, limit := g.Evaluate(used, subject)
if has {
found = true
if !ok {
return false, true
return false, true, limit
}
found = true
foundLimit = min(foundLimit, limit)
}
}
}
return true, found
return true, found, foundLimit
}
func (gl *GroupList) Evaluate(used Used, forSubject LimitSubject) bool {
// Evaluate returns if the used size is acceptable for the subject and the
// lowest limit that is acceptable for the subject.
func (gl *GroupList) Evaluate(used Used, forSubject LimitSubject) (bool, int64) {
// If there are no groups, use the configured defaults:
if gl == nil || len(*gl) == 0 {
return EvaluateDefault(used, forSubject)
}
for _, group := range *gl {
ok, has := group.Evaluate(used, forSubject)
ok, has, limit := group.Evaluate(used, forSubject)
if has && ok {
return true
return true, limit
}
}
return false
return false, 0
}
func GetGroupByName(ctx context.Context, name string) (*Group, error) {

View File

@ -32,5 +32,6 @@ func EvaluateForUser(ctx context.Context, userID int64, subject LimitSubject) (b
return false, err
}
return groups.Evaluate(*used, subject), nil
acceptable, _ := groups.Evaluate(*used, subject)
return acceptable, nil
}

View File

@ -4,6 +4,7 @@
package quota_test
import (
"math"
"testing"
quota_model "code.gitea.io/gitea/models/quota"
@ -36,9 +37,10 @@ func TestQuotaGroupAllRulesMustPass(t *testing.T) {
// Within a group, *all* rules must pass. Thus, if we have a deny-all rule,
// and an unlimited rule, that will always fail.
ok, has := group.Evaluate(used, quota_model.LimitSubjectSizeAll)
ok, has, limit := group.Evaluate(used, quota_model.LimitSubjectSizeAll)
assert.True(t, has)
assert.False(t, ok)
assert.EqualValues(t, 0, limit)
}
func TestQuotaGroupRuleScenario1(t *testing.T) {
@ -66,21 +68,25 @@ func TestQuotaGroupRuleScenario1(t *testing.T) {
used.Size.Assets.Packages.All = 256
used.Size.Git.LFS = 16
ok, has := group.Evaluate(used, quota_model.LimitSubjectSizeAssetsAttachmentsReleases)
ok, has, limit := group.Evaluate(used, quota_model.LimitSubjectSizeAssetsAttachmentsReleases)
assert.True(t, has, "size:assets:attachments:releases is covered")
assert.True(t, ok, "size:assets:attachments:releases passes")
assert.EqualValues(t, 1024, limit)
ok, has = group.Evaluate(used, quota_model.LimitSubjectSizeAssetsPackagesAll)
ok, has, limit = group.Evaluate(used, quota_model.LimitSubjectSizeAssetsPackagesAll)
assert.True(t, has, "size:assets:packages:all is covered")
assert.True(t, ok, "size:assets:packages:all passes")
assert.EqualValues(t, 1024, limit)
ok, has = group.Evaluate(used, quota_model.LimitSubjectSizeGitLFS)
ok, has, limit = group.Evaluate(used, quota_model.LimitSubjectSizeGitLFS)
assert.True(t, has, "size:git:lfs is covered")
assert.False(t, ok, "size:git:lfs fails")
assert.EqualValues(t, 0, limit)
ok, has = group.Evaluate(used, quota_model.LimitSubjectSizeAll)
ok, has, limit = group.Evaluate(used, quota_model.LimitSubjectSizeAll)
assert.True(t, has, "size:all is covered")
assert.False(t, ok, "size:all fails")
assert.EqualValues(t, 0, limit)
}
func TestQuotaGroupRuleCombination(t *testing.T) {
@ -109,23 +115,27 @@ func TestQuotaGroupRuleCombination(t *testing.T) {
}
// Git LFS isn't covered by any rule
_, has := group.Evaluate(used, quota_model.LimitSubjectSizeGitLFS)
_, has, limit := group.Evaluate(used, quota_model.LimitSubjectSizeGitLFS)
assert.False(t, has)
assert.EqualValues(t, math.MaxInt, limit)
// repos:all is covered, and is passing
ok, has := group.Evaluate(used, quota_model.LimitSubjectSizeReposAll)
ok, has, limit := group.Evaluate(used, quota_model.LimitSubjectSizeReposAll)
assert.True(t, has)
assert.True(t, ok)
assert.EqualValues(t, 4096, limit)
// packages:all is covered, and is failing
ok, has = group.Evaluate(used, quota_model.LimitSubjectSizeAssetsPackagesAll)
ok, has, limit = group.Evaluate(used, quota_model.LimitSubjectSizeAssetsPackagesAll)
assert.True(t, has)
assert.False(t, ok)
assert.EqualValues(t, 0, limit)
// size:all is covered, and is failing (due to packages:all being over quota)
ok, has = group.Evaluate(used, quota_model.LimitSubjectSizeAll)
ok, has, limit = group.Evaluate(used, quota_model.LimitSubjectSizeAll)
assert.True(t, has, "size:all should be covered")
assert.False(t, ok, "size:all should fail")
assert.EqualValues(t, 0, limit)
}
func TestQuotaGroupListsRequireOnlyOnePassing(t *testing.T) {
@ -159,8 +169,9 @@ func TestQuotaGroupListsRequireOnlyOnePassing(t *testing.T) {
used.Size.Repos.Public = 1024
// In a group list, if any group passes, the entire evaluation passes.
ok := groups.Evaluate(used, quota_model.LimitSubjectSizeAll)
ok, limit := groups.Evaluate(used, quota_model.LimitSubjectSizeAll)
assert.True(t, ok)
assert.EqualValues(t, -1, limit)
}
func TestQuotaGroupListAllFailing(t *testing.T) {
@ -193,8 +204,9 @@ func TestQuotaGroupListAllFailing(t *testing.T) {
used := quota_model.Used{}
used.Size.Repos.Public = 2048
ok := groups.Evaluate(used, quota_model.LimitSubjectSizeAll)
ok, limit := groups.Evaluate(used, quota_model.LimitSubjectSizeAll)
assert.False(t, ok)
assert.EqualValues(t, 0, limit)
}
func TestQuotaGroupListEmpty(t *testing.T) {
@ -203,6 +215,7 @@ func TestQuotaGroupListEmpty(t *testing.T) {
used := quota_model.Used{}
used.Size.Repos.Public = 2048
ok := groups.Evaluate(used, quota_model.LimitSubjectSizeAll)
ok, limit := groups.Evaluate(used, quota_model.LimitSubjectSizeAll)
assert.True(t, ok)
assert.EqualValues(t, -1, limit)
}

View File

@ -20,6 +20,22 @@ func (r *Rule) TableName() string {
return "quota_rule"
}
func (r Rule) Acceptable(used Used) bool {
if r.Limit == -1 {
return true
}
return r.Sum(used) <= r.Limit
}
func (r Rule) Sum(used Used) int64 {
var sum int64
for _, subject := range r.Subjects {
sum += used.CalculateFor(subject)
}
return sum
}
func (r Rule) Evaluate(used Used, forSubject LimitSubject) (bool, bool) {
// If there's no limit, short circuit out
if r.Limit == -1 {
@ -31,11 +47,7 @@ func (r Rule) Evaluate(used Used, forSubject LimitSubject) (bool, bool) {
return false, false
}
var sum int64
for _, subject := range r.Subjects {
sum += used.CalculateFor(subject)
}
return sum <= r.Limit, true
return r.Sum(used) <= r.Limit, true
}
func (r *Rule) Edit(ctx context.Context, limit *int64, subjects *LimitSubjects) (*Rule, error) {

View File

@ -749,6 +749,8 @@ organization = Organizations
uid = UID
webauthn = Two-factor authentication (Security keys)
blocked_users = Blocked users
storage_overview = Storage overview
quota = Quota
public_profile = Public profile
biography_placeholder = Tell others a little bit about yourself! (Markdown is supported)
@ -1054,6 +1056,25 @@ user_unblock_success = The user has been unblocked successfully.
user_block_success = The user has been blocked successfully.
user_block_yourself = You cannot block yourself.
quota.applies_to_user = The following quota rules apply to your account
quota.applies_to_org = The following quota rules apply to this organisation
quota.rule.exceeded = Exceeded
quota.rule.exceeded.helper = The total size of objects for this rule has exceeded the quota.
quota.rule.no_limit = Unlimited
quota.sizes.all = All
quota.sizes.repos.all = Repositories
quota.sizes.repos.public = Public repositories
quota.sizes.repos.private = Private repositories
quota.sizes.git.all = Git content
quota.sizes.git.lfs = Git LFS
quota.sizes.assets.all = Assets
quota.sizes.assets.attachments.all = Attachments
quota.sizes.assets.attachments.issues = Issue attachments
quota.sizes.assets.attachments.releases = Release attachments
quota.sizes.assets.artifacts = Artifacts
quota.sizes.assets.packages.all = Packages
quota.sizes.wiki = Wiki
[repo]
rss.must_be_on_branch = You must be on a branch to have an RSS feed.

View File

@ -0,0 +1,20 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package setting
import (
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/routers/web/shared"
"code.gitea.io/gitea/services/context"
)
const (
tplSettingsStorageOverview base.TplName = "org/settings/storage_overview"
)
// StorageOverview render a size overview of the organization, as well as relevant
// quota limits of the instance.
func StorageOverview(ctx *context.Context) {
shared.StorageOverview(ctx, ctx.Org.Organization.ID, tplSettingsStorageOverview)
}

View File

@ -0,0 +1,90 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package shared
import (
"html/template"
"net/http"
quota_model "code.gitea.io/gitea/models/quota"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/services/context"
)
// StorageOverview render a size overview of the user, as well as relevant
// quota limits of the instance.
func StorageOverview(ctx *context.Context, userID int64, tpl base.TplName) {
if !setting.Quota.Enabled {
ctx.NotFound("MustEnableQuota", nil)
}
ctx.Data["Title"] = ctx.Tr("settings.storage_overview")
ctx.Data["PageIsStorageOverview"] = true
ctx.Data["Color"] = func(subject quota_model.LimitSubject) float64 {
return float64(subject) * 137.50776405003785 // Golden angle.
}
ctx.Data["PrettySubject"] = func(subject quota_model.LimitSubject) template.HTML {
switch subject {
case quota_model.LimitSubjectSizeAll:
return ctx.Locale.Tr("settings.quota.sizes.all")
case quota_model.LimitSubjectSizeReposAll:
return ctx.Locale.Tr("settings.quota.sizes.repos.all")
case quota_model.LimitSubjectSizeReposPublic:
return ctx.Locale.Tr("settings.quota.sizes.repos.public")
case quota_model.LimitSubjectSizeReposPrivate:
return ctx.Locale.Tr("settings.quota.sizes.repos.private")
case quota_model.LimitSubjectSizeGitAll:
return ctx.Locale.Tr("settings.quota.sizes.git.all")
case quota_model.LimitSubjectSizeGitLFS:
return ctx.Locale.Tr("settings.quota.sizes.git.lfs")
case quota_model.LimitSubjectSizeAssetsAll:
return ctx.Locale.Tr("settings.quota.sizes.assets.all")
case quota_model.LimitSubjectSizeAssetsAttachmentsAll:
return ctx.Locale.Tr("settings.quota.sizes.assets.attachments.all")
case quota_model.LimitSubjectSizeAssetsAttachmentsIssues:
return ctx.Locale.Tr("settings.quota.sizes.assets.attachments.issues")
case quota_model.LimitSubjectSizeAssetsAttachmentsReleases:
return ctx.Locale.Tr("settings.quota.sizes.assets.attachments.releases")
case quota_model.LimitSubjectSizeAssetsArtifacts:
return ctx.Locale.Tr("settings.quota.sizes.assets.artifacts")
case quota_model.LimitSubjectSizeAssetsPackagesAll:
return ctx.Locale.Tr("settings.quota.sizes.assets.packages.all")
case quota_model.LimitSubjectSizeWiki:
return ctx.Locale.Tr("settings.quota.sizes.wiki")
default:
panic("unrecognized subject: " + subject.String())
}
}
sizeUsed, err := quota_model.GetUsedForUser(ctx, userID)
if err != nil {
ctx.ServerError("GetUsedForUser", err)
return
}
ctx.Data["SizeUsed"] = sizeUsed
quotaGroups, err := quota_model.GetGroupsForUser(ctx, userID)
if err != nil {
ctx.ServerError("GetGroupsForUser", err)
return
}
if len(quotaGroups) == 0 {
quotaGroups = append(quotaGroups, &quota_model.Group{
Name: "Global quota",
Rules: []quota_model.Rule{
{
Name: "Default",
Limit: setting.Quota.Default.Total,
Subjects: quota_model.LimitSubjects{quota_model.LimitSubjectSizeAll},
},
},
},
)
}
ctx.Data["QuotaGroups"] = quotaGroups
ctx.HTML(http.StatusOK, tpl)
}

View File

@ -0,0 +1,20 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package setting
import (
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/routers/web/shared"
"code.gitea.io/gitea/services/context"
)
const (
tplSettingsStorageOverview base.TplName = "user/settings/storage_overview"
)
// StorageOverview render a size overview of the user, as well as relevant
// quota limits of the instance.
func StorageOverview(ctx *context.Context) {
shared.StorageOverview(ctx, ctx.Doer.ID, tplSettingsStorageOverview)
}

View File

@ -644,7 +644,8 @@ func registerRoutes(m *web.Route) {
m.Get("", user_setting.BlockedUsers)
m.Post("/unblock", user_setting.UnblockUser)
})
}, reqSignIn, ctxDataSet("PageIsUserSettings", true, "AllThemes", setting.UI.Themes, "EnablePackages", setting.Packages.Enabled))
m.Get("/storage_overview", user_setting.StorageOverview)
}, reqSignIn, ctxDataSet("PageIsUserSettings", true, "AllThemes", setting.UI.Themes, "EnablePackages", setting.Packages.Enabled, "EnableQuota", setting.Quota.Enabled))
m.Group("/user", func() {
m.Get("/activate", auth.Activate)
@ -930,6 +931,7 @@ func registerRoutes(m *web.Route) {
m.Post("/block", org_setting.BlockedUsersBlock)
m.Post("/unblock", org_setting.BlockedUsersUnblock)
})
m.Get("/storage_overview", org_setting.StorageOverview)
m.Group("/packages", func() {
m.Get("", org.Packages)
@ -949,7 +951,7 @@ func registerRoutes(m *web.Route) {
m.Post("/rebuild", org.RebuildCargoIndex)
})
}, packagesEnabled)
}, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled, "PageIsOrgSettings", true))
}, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled, "EnableQuota", setting.Quota.Enabled, "PageIsOrgSettings", true))
}, context.OrgAssignment(true, true))
}, reqSignIn)
// ***** END: Organization *****

View File

@ -38,9 +38,14 @@
</div>
</details>
{{end}}
<a class="{{if .PageIsSettingsBlockedUsers}}active {{end}}item" href="{{.OrgLink}}/settings/blocked_users">
<a class="{{if .PageIsSettingsBlockedUsers}}active {{end}}item" href="{{.OrgLink}}/settings/blocked_users">
{{ctx.Locale.Tr "settings.blocked_users"}}
</a>
{{if .EnableQuota}}
<a class="{{if .PageIsSettingsStorageOverview}}active {{end}}item" href="{{.OrgLink}}/settings/storage_overview">
{{ctx.Locale.Tr "settings.storage_overview"}}
</a>
{{end}}
<a class="{{if .PageIsSettingsDelete}}active {{end}}item" href="{{.OrgLink}}/settings/delete">
{{ctx.Locale.Tr "org.settings.delete"}}
</a>

View File

@ -0,0 +1,5 @@
{{template "org/settings/layout_head" (dict "ctxData" . "pageClass" "organization settings size-overview")}}
<div class="org-setting-content">
{{template "shared/quota_overview" .}}
</div>
{{template "org/settings/layout_footer" .}}

View File

@ -0,0 +1,32 @@
<h4 class="ui top attached header">
{{ctx.Locale.Tr "settings.quota"}}
</h4>
<div class="ui attached segment">
<p>{{if .ContextUser.IsOrganization}}{{ctx.Locale.Tr "settings.quota.applies_to_org"}}{{else}}{{ctx.Locale.Tr "settings.quota.applies_to_user"}}{{end}}:</p>
{{range $group := .QuotaGroups}}
<p class="tw-my-4"><strong>{{$group.Name}}</strong></p>
<div class="tw-ml-4">
{{range $rule := .Rules}}
<div class="tw-flex tw-justify-between">
<span class="tw-flex tw-items-center tw-gap-2{{if eq $rule.Limit -1}} tw-mb-5{{end}}">
{{if $rule.Acceptable ($.SizeUsed)}}
{{svg "octicon-check-circle-fill" 16 "text green"}}
{{$rule.Name}}
{{else}}
{{svg "octicon-alert-fill" 16 "text red"}}
<span data-tooltip-content="{{ctx.Locale.Tr "settings.quota.rule.exceeded.helper"}}" data-tooltip-placement="top">
{{$rule.Name}} {{ctx.Locale.Tr "settings.quota.rule.exceeded"}}
</span>
{{end}}
</span>
<span>{{ctx.Locale.TrSize ($rule.Sum $.SizeUsed)}} / {{if eq $rule.Limit -1 -}}{{ctx.Locale.Tr "settings.quota.rule.no_limit"}}{{else}}{{ctx.Locale.TrSize $rule.Limit}}{{end}}</span>
</div>
<div class="ui segment">
{{range $idx, $subject := .Subjects}}
<div class="bar" style="width: calc(max(1%, {{Eval 100.0 "*" ($.SizeUsed.CalculateFor $subject) "/" $rule.Limit}}%)); background-color: oklch(80% 30% {{call $.Color $subject}}deg)" data-tooltip-placement="top" data-tooltip-content="{{call $.PrettySubject $subject}} {{ctx.Locale.TrSize ($.SizeUsed.CalculateFor $subject)}}" data-tooltip-follow-cursor="horizontal"></div>
{{end}}
</div>
{{end}}
</div>
{{end}}
</div>

View File

@ -51,6 +51,11 @@
<a class="{{if .PageIsSettingsRepos}}active {{end}}item" href="{{AppSubUrl}}/user/settings/repos">
{{ctx.Locale.Tr "settings.repos"}}
</a>
{{if .EnableQuota}}
<a class="{{if .PageIsStorageOverview}}active {{end}}item" href="{{AppSubUrl}}/user/settings/storage_overview">
{{ctx.Locale.Tr "settings.storage_overview"}}
</a>
{{end}}
<a class="{{if .PageIsBlockedUsers}}active {{end}}item" href="{{AppSubUrl}}/user/settings/blocked_users">
{{ctx.Locale.Tr "settings.blocked_users"}}
</a>

View File

@ -0,0 +1,5 @@
{{template "user/settings/layout_head" (dict "ctxData" . "pageClass" "user settings size-overview")}}
<div class="user-setting-content">
{{template "shared/quota_overview" .}}
</div>
{{template "user/settings/layout_footer" .}}

View File

@ -37,6 +37,7 @@ func TestMain(m *testing.M) {
defer cancel()
tests.InitTest(true)
setting.Quota.Enabled = true
initChangedFiles()
testE2eWebRoutes = routers.NormalRoutes()

View File

@ -0,0 +1,12 @@
-
id: 1001
uuid: 5792c349-8a26-46b8-b4cd-2c290f118285
repo_id: 1
issue_id: 1
release_id: 0
uploader_id: 3
comment_id: 1
name: forgejo-secrets.txt
download_count: 0
size: 536870911
created_unix: 1730000000

View File

@ -0,0 +1 @@
- name: trusted-user

View File

@ -0,0 +1,5 @@
-
id: 1001
kind: 0
mapped_id: 2
group_name: trusted-user

View File

@ -0,0 +1,14 @@
-
id: 1001
group_name: trusted-user
rule_name: git-lfs
-
id: 1002
group_name: trusted-user
rule_name: "all:assets"
-
id: 1003
group_name: trusted-user
rule_name: "Multi subjects"

View File

@ -0,0 +1,13 @@
-
name: git-lfs
limit: 512
subjects: [6]
-
name: "all:assets"
limit: -1
subjects: [7]
- name: "Multi subjects"
limit: 5000000000
subjects: [8,6]

View File

@ -0,0 +1,12 @@
-
id: 1001
owner_id: 2
owner_name: user2
lower_name: large-lfs
name: large-lfs
default_branch: main
is_empty: false
is_archived: false
is_private: true
status: 0
lfs_size: 8192

View File

@ -63,3 +63,12 @@ test('User: Profile settings', async ({browser}, workerInfo) => {
await page.goto('/user2?tab=activity');
await expect(page.getByText('Your activity is visible to everyone')).toBeVisible();
});
test('User: Storage overview', async ({browser}, workerInfo) => {
const page = await login({browser}, workerInfo);
await page.goto('/user/settings/storage_overview');
await page.waitForLoadState();
await page.getByLabel('Git LFS 8 KiB').nth(1).hover({position: {x: 250, y: 2}});
await expect(page.getByText('Git LFS')).toBeVisible();
await save_visual(page);
});

View File

@ -2035,6 +2035,14 @@ details.repo-search-result summary::marker {
overflow: hidden;
}
.size-overview .segment:has(> .bar) {
display: flex;
height: 10px;
padding: 0;
border-radius: 3px;
overflow: hidden;
}
#cite-repo-modal #citation-panel {
display: flex;
width: 100%;