Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/admin/dashboard/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import (
"github.com/labstack/echo/v5"
)

//go:embed templates/*.html static/css/*.css static/js/*.js static/js/modules/*.js static/*.svg
//go:embed templates/*.html static/css/*.css static/js/*.js static/js/modules/*.js static/vendor/*.js static/fonts/*.css static/fonts/*.woff2 static/*.svg
var content embed.FS

// Handler serves the admin dashboard UI.
Expand Down
73 changes: 73 additions & 0 deletions internal/admin/dashboard/dashboard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -317,3 +317,76 @@ func TestStatic_NotFound(t *testing.T) {
t.Errorf("expected 404, got %d", rec.Code)
}
}

// TestIndex_HasNoExternalResources guards the offline guarantee: the rendered
// page must load every script, style, and font from the embedded /admin/static
// tree, never from a CDN or remote font host.
func TestIndex_HasNoExternalResources(t *testing.T) {
h, err := New()
if err != nil {
t.Fatalf("New() returned error: %v", err)
}

e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/admin/dashboard", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)

if err := h.Index(c); err != nil {
t.Fatalf("Index() returned error: %v", err)
}

// Match resources the browser actually fetches (src=, href=, CSS url()),
// including protocol-relative (//cdn...) URLs, while ignoring inline-SVG
// namespace URIs like http://www.w3.org/2000/svg.
loaded := regexp.MustCompile(`(?:src|href)=["'](?:https?:)?//|url\(\s*["']?(?:https?:)?//`)
if matches := loaded.FindAllString(rec.Body.String(), -1); len(matches) > 0 {
t.Errorf("expected no external (http/https) resources in page HTML, found: %v", matches)
}
for _, want := range []string{
`/admin/static/fonts/inter.css`,
`/admin/static/vendor/chart.umd.min.js`,
`/admin/static/vendor/alpine.min.js`,
`/admin/static/vendor/lucide.min.js`,
} {
if !strings.Contains(rec.Body.String(), want) {
t.Errorf("expected local asset %q in page HTML", want)
}
}
}

// TestStatic_ServesVendoredAssets confirms the vendored libraries and font
// files are embedded and served, so the dashboard renders without network access.
func TestStatic_ServesVendoredAssets(t *testing.T) {
h, err := New()
if err != nil {
t.Fatalf("New() returned error: %v", err)
}

paths := []string{
"/admin/static/vendor/chart.umd.min.js",
"/admin/static/vendor/alpine.min.js",
"/admin/static/vendor/lucide.min.js",
"/admin/static/fonts/inter.css",
"/admin/static/fonts/inter-latin.woff2",
"/admin/static/fonts/inter-latin-ext.woff2",
}
for _, path := range paths {
t.Run(path, func(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)

if err := h.Static(c); err != nil {
t.Fatalf("Static() returned error: %v", err)
}
if rec.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rec.Code)
}
if rec.Body.Len() == 0 {
t.Errorf("expected non-empty body for %s", path)
}
})
}
}
Binary file not shown.
Binary file not shown.
18 changes: 18 additions & 0 deletions internal/admin/dashboard/static/fonts/inter.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/* Inter (variable, latin + latin-ext subsets) — vendored from Google Fonts for offline use.
v20 ships one variable woff2 per subset, so a single @font-face covers weights 400-700. */
@font-face {
font-family: 'Inter';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix Stylelint font-family-name-quotes violations.

The quoted Inter family name fails the configured lint rule and can break CI.

Suggested fix
-  font-family: 'Inter';
+  font-family: Inter;
-  font-family: 'Inter';
+  font-family: Inter;

Also applies to: 12-12

🧰 Tools
🪛 Stylelint (17.13.0)

[error] 4-4: Expected no quotes around "Inter" (font-family-name-quotes)

(font-family-name-quotes)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/admin/dashboard/static/fonts/inter.css` at line 4, The font-family
name 'Inter' is quoted in violation of the Stylelint font-family-name-quotes
rule, which fails CI checks. Remove the single quotes surrounding Inter in all
font-family declarations in the inter.css file (at lines 4 and 12 as indicated)
so that the font-family property reads font-family: Inter; without any quotes
around the family name.

Source: Linters/SAST tools

font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url(inter-latin-ext.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url(inter-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ test("mono utility only sets the font family and font-size-md carries the 13px s
assert.match(fontSizeMdRule, /font-size:\s*13px/);
});

test("dashboard layout pins Chart.js to 4.5.0 and avoids unused htmx", () => {
test("dashboard layout serves fonts and JS libraries locally for offline use", () => {
const template = readFixture("../../../templates/layout.html");

assert.match(
Expand All @@ -144,16 +144,26 @@ test("dashboard layout pins Chart.js to 4.5.0 and avoids unused htmx", () => {
);
assert.match(
template,
/<script src="https:\/\/cdn\.jsdelivr\.net\/npm\/chart\.js@4\.5\.0\/dist\/chart\.umd\.min\.js" integrity="sha384-XcdcwHqIPULERb2yDEM4R0XaQKU3YnDsrTmjACBZyfdVVqjh6xQ4\/DCMd7XLcA6Y" crossorigin="anonymous"><\/script>/,
/<link rel="stylesheet" href="{{assetURL "fonts\/inter\.css"}}">/,
);
assert.match(
template,
/<script defer src="https:\/\/cdn\.jsdelivr\.net\/npm\/alpinejs@3\.15\.8\/dist\/cdn\.min\.js" integrity="sha384-LXWjKwDZz29o7TduNe\+r\/UxaolHh5FsSvy2W7bDHSZ8jJeGgDeuNnsDNHoxpSgDi" crossorigin="anonymous"><\/script>/,
/<script src="{{assetURL "vendor\/chart\.umd\.min\.js"}}"><\/script>/,
);
assert.match(
template,
/<script src="https:\/\/cdn\.jsdelivr\.net\/npm\/lucide@0\.577\.0\/dist\/umd\/lucide\.min\.js" integrity="sha384-orgVf2eX2\+m1zKAOIi09hD0W6GtVhoOUmqDK\+sysYB2JTZ4vS86j4jm\+X7a4Nnei" crossorigin="anonymous"><\/script>/,
/<script defer src="{{assetURL "vendor\/alpine\.min\.js"}}"><\/script>/,
);
assert.match(
template,
/<script src="{{assetURL "vendor\/lucide\.min\.js"}}"><\/script>/,
);
// No external resources: the dashboard must render fully offline.
assert.doesNotMatch(
template,
/https?:\/\/(cdn\.jsdelivr\.net|fonts\.googleapis\.com|fonts\.gstatic\.com)/,
);
assert.doesNotMatch(template, /<link rel="preconnect"/);
assert.doesNotMatch(template, /htmx/i);
assert.match(
template,
Expand Down
5 changes: 5 additions & 0 deletions internal/admin/dashboard/static/vendor/alpine.min.js

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions internal/admin/dashboard/static/vendor/chart.umd.min.js

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions internal/admin/dashboard/static/vendor/lucide.min.js

Large diffs are not rendered by default.

10 changes: 4 additions & 6 deletions internal/admin/dashboard/templates/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GoModel Dashboard</title>
<link rel="icon" type="image/svg+xml" href="{{assetURL "favicon.svg"}}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.5.0/dist/chart.umd.min.js" integrity="sha384-XcdcwHqIPULERb2yDEM4R0XaQKU3YnDsrTmjACBZyfdVVqjh6xQ4/DCMd7XLcA6Y" crossorigin="anonymous"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.8/dist/cdn.min.js" integrity="sha384-LXWjKwDZz29o7TduNe+r/UxaolHh5FsSvy2W7bDHSZ8jJeGgDeuNnsDNHoxpSgDi" crossorigin="anonymous"></script>
<link rel="stylesheet" href="{{assetURL "fonts/inter.css"}}">
<script src="{{assetURL "vendor/chart.umd.min.js"}}"></script>
<script defer src="{{assetURL "vendor/alpine.min.js"}}"></script>
<link rel="stylesheet" href="{{assetURL "css/dashboard.css"}}">
<script>
(function () {
Expand Down Expand Up @@ -131,7 +129,7 @@ <h2 id="authDialogTitle" x-text="needsAuth ? 'Dashboard locked' : 'Change API ke
</div>
{{template "typed-confirmation-dialog" .}}
</div>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.577.0/dist/umd/lucide.min.js" integrity="sha384-orgVf2eX2+m1zKAOIi09hD0W6GtVhoOUmqDK+sysYB2JTZ4vS86j4jm+X7a4Nnei" crossorigin="anonymous"></script>
<script src="{{assetURL "vendor/lucide.min.js"}}"></script>
<script src="{{assetURL "js/modules/conversation-helpers.js"}}"></script>
<script src="{{assetURL "js/modules/icons.js"}}"></script>
<script src="{{assetURL "js/modules/clipboard.js"}}"></script>
Expand Down
14 changes: 14 additions & 0 deletions internal/admin/handler_audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ import (
// matches the value documented in the @Param limit annotation below.
const maxAuditLogLimit = 100

// defaultAuditLogLimit is the effective page size when the caller omits limit.
// It mirrors the reader's pagination default so the disabled-reader fast path
// reports the same limit an enabled reader would.
const defaultAuditLogLimit = 25

// AuditLog handles GET /admin/audit/log
//
// @Summary Get paginated audit log entries
Expand Down Expand Up @@ -109,8 +114,17 @@ func (h *Handler) AuditLog(c *echo.Context) error {
}

if h.auditReader == nil {
// Echo the effective pagination so the response matches the enabled-reader
// contract. Returning limit:0 here would make the client send limit=0 on
// its next request, which fails validation above with a 400.
limit := params.Limit
if limit <= 0 {
limit = defaultAuditLogLimit
}
return c.JSON(http.StatusOK, auditLogListResponse{
Entries: []auditLogEntryResponse{},
Limit: limit,
Offset: params.Offset,
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

Expand Down
18 changes: 18 additions & 0 deletions internal/admin/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,9 @@ func TestUsageByUserPath_Error(t *testing.T) {

func TestUsageLog_NilReader(t *testing.T) {
h := NewHandler(nil, nil)
// Omit limit, as a paging client's first request may. The disabled-reader
// path must report the default page size (not 0) so the client never resends
// limit=0 (which 400s).
c, rec := newHandlerContext("/admin/usage/log")

if err := h.UsageLog(c); err != nil {
Expand All @@ -610,6 +613,12 @@ func TestUsageLog_NilReader(t *testing.T) {
if len(result.Entries) != 0 {
t.Errorf("expected 0 entries, got %d", len(result.Entries))
}
if result.Offset != 0 {
t.Errorf("expected echoed offset 0, got %d", result.Offset)
}
if result.Limit != 50 {
t.Errorf("expected default echoed limit 50, got %d", result.Limit)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

func TestUsageLog_Success(t *testing.T) {
Expand Down Expand Up @@ -746,6 +755,9 @@ func TestUsageLog_WithFilters(t *testing.T) {

func TestAuditLog_NilReader(t *testing.T) {
h := NewHandler(nil, nil)
// Omit limit, as a paging client's first request may. The disabled-reader
// path must report the default page size (not 0) so the client never resends
// limit=0 (which 400s).
c, rec := newHandlerContext("/admin/audit/log")

if err := h.AuditLog(c); err != nil {
Expand All @@ -762,6 +774,12 @@ func TestAuditLog_NilReader(t *testing.T) {
if len(result.Entries) != 0 {
t.Errorf("expected 0 entries, got %d", len(result.Entries))
}
if result.Offset != 0 {
t.Errorf("expected echoed offset 0, got %d", result.Offset)
}
if result.Limit != 25 {
t.Errorf("expected default echoed limit 25, got %d", result.Limit)
}
}

func TestAuditLog_Success(t *testing.T) {
Expand Down
14 changes: 14 additions & 0 deletions internal/admin/handler_usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ import (
// matches the value documented in the @Param limit annotation below.
const maxUsageLogLimit = 200

// defaultUsageLogLimit is the effective page size when the caller omits limit.
// It mirrors the reader's pagination default so the disabled-reader fast path
// reports the same limit an enabled reader would.
const defaultUsageLogLimit = 50

// UsageSummary handles GET /admin/usage/summary
//
// @Summary Get usage summary
Expand Down Expand Up @@ -200,8 +205,17 @@ func (h *Handler) UsageLog(c *echo.Context) error {
}

if h.usageReader == nil {
// Echo the effective pagination so the response matches the enabled-reader
// contract. Returning limit:0 here would make the client send limit=0 on
// its next request, which fails validation above with a 400.
limit := params.Limit
if limit <= 0 {
limit = defaultUsageLogLimit
}
return c.JSON(http.StatusOK, usage.UsageLogResult{
Entries: []usage.UsageLogEntry{},
Limit: limit,
Offset: params.Offset,
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

Expand Down