feat: activity feed — enriched audit events + global/project timelines (#45)

* feat(branding): white-label — public /branding, admin write, shell theming

app_settings branding.* keys (product name, logo/favicon URLs, accent
color, docs/support links). Public GET /branding so login and shell
theme pre-auth; admin writes via PUT /settings.

useBranding() applies document.title, accent CSS var and favicon;
BrandWordmark replaces hardcoded wordmarks; admin Platform settings
gain a Branding form. MCP containr_branding_get + settings_update.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(invites): team invite links — admin create/revoke, public accept

Admins mint single-use invite links (sha256-hashed token at rest,
optional email binding, configurable TTL). Public endpoints let the
accept page validate the link and register — working even while
public signup is disabled.

- migration + sqlc queries/model for user_invites
- POST /admin/invites (returns one-time token+url), GET list, DELETE revoke
- GET /auth/invites/:token public validation, POST /auth/accept-invite
- CLI containr invites list|create|revoke; MCP invites tools
- /auth/accept-invite page (expired/used/invalid states, bound-email lock)
- Admin page Team invites section: create, copy link, revoke
- OpenAPI + generated types + CLI guide

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(admin): impersonate user — 15-min token, audit-logged

POST /admin/users/:id/impersonate mints a short-lived bearer token
(impersonated_by claim) for support/debugging. Admin accounts cannot
be impersonated; every mint is audit-logged with actor + target.

- CLI: containr admin impersonate <user-id> (works via CONTAINR_TOKEN)
- MCP: containr_admin_impersonate
- Admin UI: per-user Impersonate action → token modal with usage hint
- OpenAPI + generated types

Verified live: token resolves to target, admin target 403, audit row.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(git): GitHub App manifest self-provisioning + app-level webhook

Admins create the instance GitHub App in-browser instead of hand-
setting GITHUB_APP_* env vars:

- POST /admin/git/github-app/manifest builds the manifest + URL;
  the frontend auto-submits it to github.com
- /git/github-app/callback page exchanges the code via
  POST /admin/git/github-app/convert; credentials persist in
  app_settings (precedence over env)
- GET /admin/git/github-app reports configured/source
- POST /api/git/github-app/webhook — single app-level push receiver,
  HMAC-verified, dispatches via extracted dispatchPushToServices
- install-url + installation-token paths resolve settings→env
- CLI: containr admin github-app status|manifest|convert; MCP tools
- Git providers UI shows "Set up GitHub App" for admins when
  unconfigured

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat: activity feed — enriched audit events, global + per-project timelines

audit_logs gains severity/category/label columns derived from
resource+action at write time; legacy rows are classified on read and
backfilled at boot in batches.

- GET /activity: global feed scoped to the caller's visible projects
  (admins see all); severity/category/resource/project_id/since
  filters, page+limit
- GET /projects/:id/activity: timeline correlated to the project's
  services, deployments, databases and the project record; access
  mirrors project read rules
- Retention worker prunes rows older than AUDIT_RETENTION_DAYS
  (default 90, 0 disables), running once at boot then daily
- project.create/delete now audited so timelines aren't empty
- CLI `containr activity` with filter flags; MCP tools
  containr_activity_list + containr_project_activity
- Frontend: /activity page (severity chips, icon per category) under
  Operate nav; project workspace gains an Activity view tab
- OpenAPI + generated types + CLI docs

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomáš DvořákandDevin authored and GitHub committed 2026-10-09 10:52:15 +02:00
1 parent 00e69b3733
commit 5eac4a382b
19 files changed
+905 -11

No files matched your search

@@ -0,0 +1,121 @@
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { listActivity, type ActivityEntry } from '@/lib/api-client';
import { useDemoMode } from '@/lib/demo-mode';
import { formatRelative } from '@/lib/time';
import { DemoRestricted } from '@/shared/components';
import {
Activity, AlertCircle, CheckCircle2, Database, GitBranch, Info, Key,
Loader2, Package, Projector, Rocket, Shield, User, Users, Wrench,
} from 'lucide-react';
const CATEGORY_ICONS: Record<string, typeof Activity> = {
service: Rocket, deployment: Package, project: Projector,
database: Database, user: User, team: Users, auth: Key,
security: Shield, git: GitBranch, webhook: GitBranch, system: Wrench,
};
const SEVERITY_STYLE: Record<string, { icon: typeof Info; cls: string; dot: string }> = {
error: { icon: AlertCircle, cls: 'text-[var(--error)]', dot: 'bg-[var(--error)]' },
warning: { icon: AlertCircle, cls: 'text-[var(--warning)]', dot: 'bg-[var(--warning)]' },
success: { icon: CheckCircle2, cls: 'text-[var(--success)]', dot: 'bg-[var(--success)]' },
info: { icon: Info, cls: 'text-[var(--accent-primary)]', dot: 'bg-[var(--accent-primary)]' },
};
const SEVERITIES = ['', 'error', 'warning', 'success', 'info'] as const;
export function ActivityPage() {
const isDemoMode = useDemoMode();
const [severity, setSeverity] = useState('');
const [page, setPage] = useState(1);
const query = useQuery({
queryKey: ['activity-feed', severity, page],
queryFn: () => listActivity({ severity: severity || undefined, page, limit: 50 }),
enabled: !isDemoMode,
refetchInterval: 15000,
});
if (isDemoMode) return <DemoRestricted feature="Activity" />;
const entries = query.data?.activity ?? [];
return (
<div className="min-h-screen">
<div className="border-b border-[var(--border-subtle)]">
<div className="w-full px-8 py-5">
<h1 className="v-title">Activity<span className="v-cursor">_</span></h1>
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">
platform events across your visible projects
</p>
</div>
</div>
<div className="px-8 py-6 space-y-4">
<div className="flex items-center gap-2">
{SEVERITIES.map((s) => (
<button
key={s || 'all'}
onClick={() => { setSeverity(s); setPage(1); }}
className={`v-mono text-[10px] uppercase px-3 py-1.5 rounded-md border transition-colors ${
severity === s
? 'border-[var(--accent-primary)] text-[var(--accent-primary)] bg-[var(--accent-primary)]/10'
: 'border-[var(--border-subtle)] text-[var(--text-muted)] hover:text-[var(--text-primary)]'
}`}
>
{s || 'all'}
</button>
))}
{query.isFetching && <Loader2 size={14} className="animate-spin text-[var(--text-muted)]" />}
</div>
<div className="panel p-0 divide-y divide-[var(--border-subtle)]">
{query.isLoading && <p className="px-5 py-8 text-xs text-[var(--text-muted)]">Loading…</p>}
{!query.isLoading && entries.length === 0 && (
<p className="px-5 py-8 text-xs text-[var(--text-muted)]">No events yet — activity appears here as you deploy, configure, and manage services.</p>
)}
{entries.map((e) => <Entry key={e.id} entry={e} />)}
</div>
<div className="flex items-center gap-3">
<button
disabled={page <= 1}
onClick={() => setPage(page - 1)}
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-muted)] disabled:opacity-40 hover:text-[var(--text-primary)]"
>
newer
</button>
<button
disabled={!query.data?.has_more}
onClick={() => setPage(page + 1)}
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-muted)] disabled:opacity-40 hover:text-[var(--text-primary)]"
>
older
</button>
</div>
</div>
</div>
);
}
function Entry({ entry }: { entry: ActivityEntry }) {
const Cat = CATEGORY_ICONS[entry.category] ?? Activity;
const sev = SEVERITY_STYLE[entry.severity] ?? SEVERITY_STYLE.info;
return (
<div className="flex items-center gap-3 px-5 py-3">
<div className={`w-8 h-8 rounded-md bg-[var(--bg-elevated)] flex items-center justify-center ${sev.cls}`}>
<Cat size={15} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-[13px] text-[var(--text-primary)] truncate">{entry.label || entry.action}</span>
<span className={`v-mono text-[9px] uppercase px-1.5 py-0.5 rounded ${sev.cls} bg-[var(--bg-elevated)]`}>{entry.severity}</span>
</div>
<div className="v-mono text-[10px] text-[var(--text-muted)] mt-0.5">
{entry.category} · {entry.action}{entry.user_email ? ` · ${entry.user_email}` : ''}
</div>
</div>
<span className="v-mono text-[10px] text-[var(--text-muted)] whitespace-nowrap">{formatRelative(entry.created_at)}</span>
</div>
);
}
@@ -13,6 +13,7 @@ import {
listGitBranches,
listGitProviders,
listGitRepositories,
listProjectActivity,
listServiceLogs,
listServiceVariables,
listServicesByProject,
@@ -49,12 +50,13 @@ import {
FileUp,
} from 'lucide-react';
type WorkspaceView = 'canvas' | 'observability' | 'logs' | 'settings';
type WorkspaceView = 'canvas' | 'observability' | 'logs' | 'activity' | 'settings';
const viewItems: Array<{ key: WorkspaceView; label: string; icon: typeof LayoutGrid; authOnly?: boolean }> = [
{ key: 'canvas', label: 'Canvas', icon: LayoutGrid },
{ key: 'observability', label: 'Observability', icon: Activity },
{ key: 'logs', label: 'Logs', icon: FileText, authOnly: true },
{ key: 'activity', label: 'Activity', icon: Clock, authOnly: true },
{ key: 'settings', label: 'Settings', icon: Settings },
];
@@ -640,6 +642,13 @@ export function ProjectWorkspacePage() {
retry: false,
});
const activityQuery = useQuery({
queryKey: ['project-activity', projectId],
queryFn: () => listProjectActivity(projectId!),
enabled: !isDemoMode && signedIn && activeView === 'activity' && !!projectId,
refetchInterval: 15000,
});
const runningServices = useMemo(() => services.filter((service) => service.status === 'running').length, [services]);
const serviceHref = (serviceId: string) =>
isDemoMode
@@ -942,6 +951,42 @@ export function ProjectWorkspacePage() {
</div>
)}
{activeView === 'activity' && (
<div className="panel p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Clock size={20} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Activity</h2>
<p className="text-sm text-[var(--text-secondary)]">Project timeline — deploys, config, membership</p>
</div>
</div>
<div className="divide-y divide-[var(--border-subtle)] border border-[var(--border-subtle)] rounded-[var(--radius-md)]">
{activityQuery.isLoading ? (
<p className="px-4 py-6 text-xs text-[var(--text-muted)]">Loading…</p>
) : (activityQuery.data?.activity?.length ?? 0) === 0 ? (
<p className="px-4 py-6 text-xs text-[var(--text-muted)]">No project events yet.</p>
) : (
activityQuery.data!.activity.map((e) => (
<div key={e.id} className="flex items-center gap-3 px-4 py-2.5">
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${
e.severity === 'error' ? 'bg-[var(--error)]' :
e.severity === 'warning' ? 'bg-[var(--warning)]' :
e.severity === 'success' ? 'bg-[var(--success)]' : 'bg-[var(--accent-primary)]'
}`} />
<div className="flex-1 min-w-0">
<span className="text-[13px] text-[var(--text-primary)]">{e.label || e.action}</span>
<span className="v-mono text-[10px] text-[var(--text-muted)] ml-2">{e.user_email}</span>
</div>
<span className="v-mono text-[10px] text-[var(--text-muted)]">{formatRelative(e.created_at)}</span>
</div>
))
)}
</div>
</div>
)}
{activeView === 'settings' && (
<div className="panel p-6">
<div className="flex items-center gap-3 mb-6">