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
4 changes: 2 additions & 2 deletions web/e2e/dependency-updates.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { test, expect, freshProject } from "./fixtures";
test("dependency updates: turn on, run now, turn off", async ({ page }) => {
const { project, web, remove } = await freshProject(page, "deps", { "package.json": '{ "dependencies": { "left-pad": "1.0.0" } }\n' });
try {
await page.goto(`${web}/-/settings`);
await page.goto(`${web}/-/settings/automation`);
const section = page.locator("section.settings-section", { has: page.getByRole("heading", { name: "Dependency updates" }) });
await section.getByRole("checkbox", { name: /update dependencies/ }).check();
await section.getByRole("button", { name: "Save", exact: true }).click();
Expand All @@ -20,7 +20,7 @@ test("dependency updates: turn on, run now, turn off", async ({ page }) => {
await expect(page.getByText(`renovate-bot-${project.id}`)).toBeVisible();

// a private registry: the password is never shown again
await page.goto(`${web}/-/settings`);
await page.goto(`${web}/-/settings/automation`);
await section.getByLabel("Host").fill("npm.example.com");
await section.getByLabel("Type").fill("npm");
await section.getByLabel("Password or token").fill("e2e-npm-token");
Expand Down
2 changes: 1 addition & 1 deletion web/e2e/everyday.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,7 +452,7 @@ test("protect tags in settings; protected tags are marked", async ({ page }) =>
const pattern = `e2e-p${Date.now() % 100000}-*`;
const tag = pattern.replace("*", "1");
try {
await page.goto(`${P}/-/settings`);
await page.goto(`${P}/-/settings/repository`);
await page.getByLabel(/^Tag or pattern/).fill(pattern);
await page.getByRole("button", { name: "Protect tags" }).click();
await expect(page.locator('[aria-label="Protected tags"]')).toContainText(pattern);
Expand Down
2 changes: 1 addition & 1 deletion web/e2e/rules.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ test("push rules are saved from settings, and a bad pattern is explained", async
const csrf = await csrfHeaders(page);
const project = await (await page.request.get(`/api/v1/resolve?path=${P.slice(1)}`)).json();
try {
await page.goto(`${P}/-/settings`);
await page.goto(`${P}/-/settings/repository`);
const field = page.getByLabel("Commit messages must match");
await field.fill("(");
await page.getByRole("button", { name: "Save push rules" }).click();
Expand Down
51 changes: 51 additions & 0 deletions web/src/components/AuditEvents.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { api, timeAgo, type S } from "../api/client";
import { Empty, Pager, Q } from "./ui";

/**
* The audit events at `path`, newest first, `perPage` at a time (what the endpoint
* returns per call; it pages with `before=<id>`). `target` adds what each event is about.
*/
export function AuditEvents({ path, perPage, target = false }: { path: string; perPage: number; target?: boolean }) {
// the `before` cursor of each page shown so far, the first page having none
const [cursors, setCursors] = useState<(number | undefined)[]>([undefined]);
const before = cursors[cursors.length - 1];
const q = useQuery({
queryKey: ["audit", path, before],
queryFn: () => api.get<S["AuditEvent"][]>(before == null ? path : `${path}?before=${before}`),
});
const page = cursors.length;
const older = () => {
const last = q.data?.at(-1);
if (last) setCursors((c) => [...c, last.id]);
};
return (
<div className="stack">
<Q q={q}>
{(events) =>
events.length === 0 ? (
<Empty>{page === 1 ? "No events." : "No older events."}</Empty>
) : (
<div className="list">
{events.map((e) => (
<div className="item small" key={e.id}>
<code>{e.action}</code>
{target && (
<span className="muted">
{e.target_type} {e.target_id}
</span>
)}
<span className="grow muted ellipsis">{JSON.stringify(e.details)}</span>
<span>{e.principal_name ?? e.principal_kind}</span>
<span className="faint">{timeAgo(e.created_at)}</span>
</div>
))}
</div>
)
}
</Q>
<Pager page={page} count={q.data?.length ?? 0} perPage={perPage} onPage={(n) => (n < page ? setCursors((c) => c.slice(0, n)) : older())} labels={["Newer", "Older"]} />
</div>
);
}
27 changes: 5 additions & 22 deletions web/src/pages/Admin.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { HostRules } from "../components/HostRules";
import { AuditEvents } from "../components/AuditEvents";
import { useState, type FormEvent } from "react";
import { Route, Routes } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
Expand Down Expand Up @@ -48,7 +49,7 @@ export function Admin() {
<Route path="settings" element={<Settings />} />
<Route path="dependency-updates" element={<DependencyUpdatesAdmin />} />
<Route path="security-scanning" element={<SecurityScanningAdmin />} />
<Route path="audit" element={<AuditLog />} />
<Route path="audit" element={<AdminAuditLog />} />
</Routes>
</div>
);
Expand Down Expand Up @@ -285,27 +286,9 @@ function SettingsForm({ initial }: { initial: S["InstanceSettings"] }) {
);
}

function AuditLog() {
const q = useQuery({ queryKey: ["admin-audit"], queryFn: () => api.get<S["AuditEvent"][]>("/admin/audit") });
return (
<Q q={q}>
{(events) => (
<div className="list">
{events.map((e) => (
<div className="item small" key={e.id}>
<code>{e.action}</code>
<span className="muted">
{e.target_type} {e.target_id}
</span>
<span className="grow muted ellipsis">{JSON.stringify(e.details)}</span>
<span>{e.principal_name ?? e.principal_kind}</span>
<span className="faint">{timeAgo(e.created_at)}</span>
</div>
))}
</div>
)}
</Q>
);
function AdminAuditLog() {
// the instance endpoint returns 200 events a call
return <AuditEvents path="/admin/audit" perPage={200} target />;
}

function DependencyUpdatesAdmin() {
Expand Down
14 changes: 14 additions & 0 deletions web/src/pages/project/Audit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { AuditEvents } from "../../components/AuditEvents";
import { ListHead } from "../../components/ui";
import type { Ctx } from "./Project";

/** Who changed what in a project, and when (Maintainers and above). */
export function ProjectAuditLog({ ctx }: { ctx: Ctx }) {
return (
<div className="stack">
<ListHead title="Audit log" sub="Who changed what in this project, and when." />
{/* the project endpoint returns 100 events a call */}
<AuditEvents path={`/projects/${ctx.p.id}/audit`} perPage={100} />
</div>
);
}
23 changes: 17 additions & 6 deletions web/src/pages/project/Project.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { useSession } from "../../session";
import { useToast } from "../../shell/toast";
import { Members } from "../../components/Members";
import { RecentBranchBanner } from "./MrStack";
import { settingsPage } from "./settingsPages";

// each section loads with its first visit (the repository's home needs none of these)
const mrs = () => import("./MergeRequests");
Expand All @@ -40,8 +41,8 @@ const JobPage = lazy(() => import("./ci/Job").then((m) => ({ default: m.JobPage
const ArtifactBrowser = lazy(() => import("./ci/Artifacts").then((m) => ({ default: m.ArtifactBrowser })));
const Schedules = lazy(() => import("./ci/Schedules").then((m) => ({ default: m.Schedules })));
const Environments = lazy(() => import("./ci/Environments").then((m) => ({ default: m.Environments })));
const CiSettings = lazy(() => import("./ci/Settings").then((m) => ({ default: m.CiSettings })));
const ProjectSettings = lazy(() => import("./Settings").then((m) => ({ default: m.ProjectSettings })));
const ProjectAuditLog = lazy(() => import("./Audit").then((m) => ({ default: m.ProjectAuditLog })));
const SecurityPage = lazy(() => import("./Security").then((m) => ({ default: m.SecurityPage })));
const Registry = lazy(() => import("./Registry").then((m) => ({ default: m.Registry })));
const EditFile = lazy(() => import("./Edit").then((m) => ({ default: m.EditFile })));
Expand All @@ -59,6 +60,7 @@ const SECTIONS: Record<string, string> = {
releases: "Releases",
container_registry: "Container registry",
security: "Security",
audit: "Audit log",
compare: "Compare",
members: "Members",
merge_requests: "Merge requests",
Expand All @@ -72,18 +74,27 @@ const SECTIONS: Record<string, string> = {
environments: "Environments",
};

/** Sections with no breadcrumb: the project's home and the file editor's actions. */
const UNTITLED = ["", "edit", "new", "delete"];
const knownSection = (section: string) => UNTITLED.includes(section) || Object.hasOwn(SECTIONS, section);

/** Sidebar + breadcrumbs for a project page. */
function ProjectNav({ p, rest, gitRef }: { p: S["ProjectDetail"]; rest: string; gitRef?: string }) {
const [section, ...more] = rest.split("/");
const crumbs: { label: string; to?: string }[] = [];
const web = `/${p.full_path}`;
if (SECTIONS[section]) crumbs.push({ label: SECTIONS[section], to: section === "commit" ? `${web}/-/commits/${gitRef ?? p.default_branch ?? "main"}` : section === "tree" || section === "blob" ? web : section === "jobs" ? `${web}/-/pipelines` : `${web}/-/${section}` });
// the audit log is shown under Security
if (section === "audit") crumbs.push({ label: "Security", to: `${web}/-/security` });
if (Object.hasOwn(SECTIONS, section)) crumbs.push({ label: SECTIONS[section], to: section === "commit" ? `${web}/-/commits/${gitRef ?? p.default_branch ?? "main"}` : section === "tree" || section === "blob" ? web : section === "jobs" ? `${web}/-/pipelines` : `${web}/-/${section}` });
if (section === "merge_requests" && more[0]) crumbs.push({ label: more[0] === "new" ? "New" : `!${more[0]}` });
if (section === "issues" && more[0]) crumbs.push({ label: more[0] === "new" ? "New" : `#${more[0]}` });
if (section === "commit" && more[0]) crumbs.push({ label: more[0].slice(0, 8) });
if (section === "pipelines" && more[0]) crumbs.push({ label: more[0] === "new" ? "Run" : `#${more[0]}` });
if (section === "jobs" && more[0]) crumbs.push({ label: `#${more[0]}` });
if (section === "settings" && more[0] === "ci_cd") crumbs.push({ label: "CI/CD" });
if (section === "settings" && more.length === 1) {
const page = settingsPage(more[0]);
if (page) crumbs.push({ label: page.label });
}
if (section === "milestones" && more[0]) crumbs.push({ label: more[0] === "new" ? "New" : more[1] === "edit" ? "Edit" : `Milestone` });
if (section === "releases" && more[0]) crumbs.push({ label: more.length === 1 && more[0] === "new" ? "New" : more.at(-1) === "edit" ? `Edit ${more.slice(0, -1).join("/")}` : more.join("/") });
useSetNav({ kind: "project", project: p, ref: gitRef, crumbs }, [p, rest, gitRef]);
Expand Down Expand Up @@ -148,6 +159,7 @@ export function ProjectPage({ id, fullPath, rest }: { id: number; fullPath: stri
{section === "releases" && <ReleaseRoute ctx={ctx} more={more} />}
{section === "container_registry" && <Registry ctx={ctx} />}
{section === "security" && <SecurityPage ctx={ctx} />}
{section === "audit" && (p.can_admin && more.length === 0 ? <ProjectAuditLog ctx={ctx} /> : <NotFound />)}
{section === "compare" && <Compare ctx={ctx} spec={tail} />}
{section === "members" && <Members base={`/projects/${id}`} canManage={p.can_admin} kind="project" />}
{section === "issues" && more.length === 0 && <IssueList ctx={ctx} />}
Expand All @@ -163,16 +175,15 @@ export function ProjectPage({ id, fullPath, rest }: { id: number; fullPath: stri
{section === "merge_requests" && /^\d+$/.test(more[0] ?? "") && (
<MergeRequestPage ctx={ctx} iid={Number(more[0])} tab={more[1] === "commits" ? "commits" : more[1] === "diffs" ? "diffs" : "overview"} />
)}
{section === "settings" && more[0] !== "ci_cd" && (p.can_admin ? <ProjectSettings ctx={ctx} /> : <NotFound />)}
{section === "settings" && more[0] === "ci_cd" && (p.can_admin ? <CiSettings ctx={ctx} /> : <NotFound />)}
{section === "settings" && (p.can_admin && more.length <= 1 ? <ProjectSettings ctx={ctx} page={more[0] ?? ""} /> : <NotFound />)}
{section === "pipelines" && more.length === 0 && <PipelineList ctx={ctx} />}
{section === "pipelines" && more[0] === "new" && <RunPipeline ctx={ctx} />}
{section === "pipelines" && /^\d+$/.test(more[0] ?? "") && <PipelinePage ctx={ctx} iid={Number(more[0])} />}
{section === "jobs" && /^\d+$/.test(more[0] ?? "") && !more[1] && <JobPage ctx={ctx} jobId={Number(more[0])} />}
{section === "jobs" && /^\d+$/.test(more[0] ?? "") && more[1] === "artifacts" && <ArtifactBrowser ctx={ctx} jobId={Number(more[0])} />}
{section === "pipeline_schedules" && (p.can_push ? <Schedules ctx={ctx} /> : <NotFound />)}
{section === "environments" && <Environments ctx={ctx} stopped={more[0] === "stopped"} />}
{!["", "tree", "blob", "blame", "edit", "new", "delete", "commits", "commit", "branches", "tags", "releases", "container_registry", "security", "compare", "members", "settings", "merge_requests", "issues", "labels", "milestones", "pipelines", "jobs", "pipeline_schedules", "environments"].includes(section) && <NotFound />}
{!knownSection(section) && <NotFound />}
</Suspense>
)}
</>
Expand Down
Loading
Loading