mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
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:
1 parent
00e69b3733
commit
5eac4a382b
19 files changed
+905
-11
No files matched your search
@@ -11,6 +11,7 @@ import { TemplatesPage } from '@/features/templates/pages/TemplatesPage';
|
||||
import { AuditLogsPage } from '@/features/audit/pages/AuditLogsPage';
|
||||
import { WebhooksPage } from '@/features/webhooks/pages/WebhooksPage';
|
||||
import { OperationsPage } from '@/features/operations/pages/OperationsPage';
|
||||
import { ActivityPage } from '@/features/activity/pages/ActivityPage';
|
||||
import { AdminPage } from '@/features/admin/pages/AdminPage';
|
||||
import { LandingPage } from '@/features/landing/LandingPage';
|
||||
import { useBranding } from '@/lib/use-branding';
|
||||
@@ -148,6 +149,7 @@ export default function App() {
|
||||
<Route path="/databases" element={<DatabasesPage />} />
|
||||
<Route path="/ha" element={<HighAvailabilityPage />} />
|
||||
<Route path="/operations" element={<OperationsPage />} />
|
||||
<Route path="/activity" element={<ActivityPage />} />
|
||||
<Route path="/security" element={<SecurityPage />} />
|
||||
<Route path="/usage" element={<UsagePage />} />
|
||||
<Route path="/docs" element={<DocsPage />} />
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ChartBar,
|
||||
CheckCircle2,
|
||||
ChevronsLeft,
|
||||
Clock,
|
||||
Container,
|
||||
Database,
|
||||
FileText,
|
||||
@@ -59,6 +60,7 @@ const navSections: NavSection[] = [
|
||||
title: 'Operate',
|
||||
items: [
|
||||
{ label: 'Operations', href: '/operations', icon: Activity, hint: 'Live jobs & failures' },
|
||||
{ label: 'Activity', href: '/activity', icon: Clock, hint: 'Platform event feed' },
|
||||
{ label: 'Databases', href: '/databases', icon: Database, hint: 'Managed data services' },
|
||||
{ label: 'High Availability', href: '/ha', icon: ShieldCheck, hint: 'Failover & health' },
|
||||
{ label: 'Security', href: '/security', icon: Shield, hint: 'Scans & compliance' },
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -3866,6 +3866,111 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/activity": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Activity feed
|
||||
* @description Enriched audit events (severity, category, label) scoped to the caller's visible projects; admins see everything.
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
severity?: "info" | "success" | "warning" | "error";
|
||||
category?: string;
|
||||
resource?: string;
|
||||
project_id?: string;
|
||||
since?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Feed entries */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
activity?: components["schemas"]["AuditLog"][];
|
||||
page?: number;
|
||||
limit?: number;
|
||||
has_more?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/projects/{id}/activity": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Per-project activity timeline
|
||||
* @description Audit events correlated to this project's services, deployments, databases and the project record itself.
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Feed entries */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
activity?: components["schemas"]["AuditLog"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Project not found or not visible */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/banners/active": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -10954,6 +11059,15 @@ export interface components {
|
||||
ip_address?: string;
|
||||
/** @description Source user agent */
|
||||
user_agent?: string;
|
||||
/**
|
||||
* @description Event severity for feed display
|
||||
* @enum {string}
|
||||
*/
|
||||
severity?: "info" | "success" | "warning" | "error";
|
||||
/** @description Coarse category (service, deployment, project, user, security, system, …) */
|
||||
category?: string;
|
||||
/** @description Human-readable event label */
|
||||
label?: string;
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description Timestamp when the audit log entry was created
|
||||
|
||||
@@ -1928,6 +1928,44 @@ export async function getOperations(): Promise<OperationsView> {
|
||||
return requestJson<OperationsView>('/operations');
|
||||
}
|
||||
|
||||
export type ActivityEntry = {
|
||||
id: string;
|
||||
user_email: string;
|
||||
resource: string;
|
||||
resource_id: string;
|
||||
action: string;
|
||||
details?: string;
|
||||
severity: 'info' | 'success' | 'warning' | 'error';
|
||||
category: string;
|
||||
label: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type ActivityPage = {
|
||||
activity: ActivityEntry[];
|
||||
page?: number;
|
||||
has_more?: boolean;
|
||||
};
|
||||
|
||||
export async function listActivity(filters: {
|
||||
severity?: string; category?: string; resource?: string;
|
||||
project_id?: string; page?: number; limit?: number;
|
||||
} = {}): Promise<ActivityPage> {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.severity) params.set('severity', filters.severity);
|
||||
if (filters.category) params.set('category', filters.category);
|
||||
if (filters.resource) params.set('resource', filters.resource);
|
||||
if (filters.project_id) params.set('project_id', filters.project_id);
|
||||
if (filters.page) params.set('page', String(filters.page));
|
||||
if (filters.limit) params.set('limit', String(filters.limit));
|
||||
const q = params.toString();
|
||||
return requestJson<ActivityPage>(`/activity${q ? `?${q}` : ''}`);
|
||||
}
|
||||
|
||||
export async function listProjectActivity(projectId: string, page = 1): Promise<ActivityPage> {
|
||||
return requestJson<ActivityPage>(`/projects/${projectId}/activity?page=${page}&limit=50`);
|
||||
}
|
||||
|
||||
export type Banner = {
|
||||
id: string;
|
||||
title: string;
|
||||
|
||||
Reference in new issue
Block a user