Cut governance bureaucracy from settings and sidebar
Settings shrinks from 14 tabs to the five that serve the product: Workspace, Billing, Providers, Skills, Migration. The deleted panels (Members/IAM, Models, Policies, Operations, SDLC, Compliance, Profile, Notifications, Security) were read-only governance tables and raw audit-log dumps with no old-app counterpart. The Automation page goes too: its rules stored empty conditions and no engine ever executed them. Sidebar reordered to match the old app hierarchy (Home, Inbox, Work, Approvals first). Backend services and schema are untouched.
This commit is contained in:
1 parent
3704cec19b
commit
a65347b91f
13 files changed
+9
-1577
No files matched your search
@@ -13,7 +13,6 @@ import { DocumentsPage } from "../pages/DocumentsPage";
|
||||
import { KnowledgePage } from "../pages/KnowledgePage";
|
||||
import { InboxPage } from "../pages/InboxPage";
|
||||
import { RunsPage } from "../pages/RunsPage";
|
||||
import { AutomationPage } from "../pages/AutomationPage";
|
||||
import { MeetingsPage } from "../pages/MeetingsPage";
|
||||
import { SprintsPage } from "../pages/SprintsPage";
|
||||
import { SettingsPage } from "../pages/SettingsPage";
|
||||
@@ -48,7 +47,6 @@ export function App() {
|
||||
knowledge: <KnowledgePage {...pageProps} />,
|
||||
inbox: <InboxPage {...pageProps} />,
|
||||
runs: <RunsPage {...pageProps} />,
|
||||
automation: <AutomationPage {...pageProps} />,
|
||||
meetings: <MeetingsPage {...pageProps} />,
|
||||
sprints: <SprintsPage {...pageProps} />,
|
||||
approvals: <ApprovalsPage {...pageProps} />,
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
import { useMemo, useState, type FormEvent } from "react";
|
||||
import { Button, Field, Icon, IconButton, Pill } from "../shared/ui";
|
||||
import { TopbarActions } from "../shared/TopbarActions";
|
||||
import { Dialog } from "../shared/Dialog";
|
||||
import type { AutomationRuleRecord } from "../../../shared/domain";
|
||||
import type { PageProps } from "./types";
|
||||
import { activeOrgId, formatRelative } from "./types";
|
||||
|
||||
const TRIGGER_EVENTS = [
|
||||
"task_created",
|
||||
"task_status_changed",
|
||||
"agent_hired",
|
||||
"approval_created",
|
||||
"message_sent",
|
||||
"file_uploaded",
|
||||
"goal_created",
|
||||
"project_created",
|
||||
] as const;
|
||||
|
||||
const ACTION_TYPES = [
|
||||
"assign_to_department",
|
||||
"create_task",
|
||||
"send_notification",
|
||||
"update_status",
|
||||
"escalate",
|
||||
] as const;
|
||||
|
||||
function text(form: FormData, key: string): string {
|
||||
return String(form.get(key) ?? "").trim();
|
||||
}
|
||||
|
||||
export function AutomationPage({ snapshot, actions }: PageProps) {
|
||||
const orgId = activeOrgId(snapshot);
|
||||
const rules = useMemo(() => snapshot.automationRules.filter((r) => r.orgId === orgId), [snapshot.automationRules, orgId]);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
||||
const editing = editingId ? rules.find((r) => r.id === editingId) ?? null : null;
|
||||
const enabledCount = rules.filter((r) => r.enabled).length;
|
||||
const totalFires = rules.reduce((sum, r) => sum + r.fireCount, 0);
|
||||
|
||||
const create = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
void actions.run(() => window.agentCompany.createAutomationRule({
|
||||
orgId,
|
||||
name: text(form, "name"),
|
||||
triggerEvent: text(form, "triggerEvent"),
|
||||
conditions: {},
|
||||
actionType: text(form, "actionType"),
|
||||
actionConfig: {},
|
||||
priority: Number(text(form, "priority")) || 100,
|
||||
enabled: true,
|
||||
})).then((r) => { if (r) setCreateOpen(false); });
|
||||
};
|
||||
|
||||
const save = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!editing) return;
|
||||
const form = new FormData(event.currentTarget);
|
||||
void actions.run(() => window.agentCompany.updateAutomationRule({
|
||||
orgId,
|
||||
ruleId: editing.id,
|
||||
name: text(form, "name"),
|
||||
triggerEvent: text(form, "triggerEvent"),
|
||||
conditions: {},
|
||||
actionType: text(form, "actionType"),
|
||||
actionConfig: {},
|
||||
priority: Number(text(form, "priority")) || editing.priority,
|
||||
enabled: form.get("enabled") === "on",
|
||||
})).then((r) => { if (r) setEditingId(null); });
|
||||
};
|
||||
|
||||
const remove = (ruleId: string) => {
|
||||
void actions.run(() => window.agentCompany.deleteAutomationRule({ orgId, ruleId }));
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<TopbarActions>
|
||||
<Button tone="primary" onClick={() => setCreateOpen(true)}><Icon name="plus" size={13} /> New rule</Button>
|
||||
</TopbarActions>
|
||||
<h1 className="page-h1">Automation</h1>
|
||||
<p className="page-sub">{enabledCount} active · {totalFires} total fires</p>
|
||||
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
<div className="table-row head" style={{ gridTemplateColumns: "minmax(0, 2fr) minmax(0, 1.2fr) minmax(0, 1.2fr) 70px 80px 50px" }}>
|
||||
<div>Name</div><div>Trigger</div><div>Action</div><div>Fires</div><div>Status</div><div />
|
||||
</div>
|
||||
{rules.map((rule, index) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="table-row clickable"
|
||||
onClick={() => setEditingId(rule.id)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") setEditingId(rule.id); }}
|
||||
style={{ gridTemplateColumns: "minmax(0, 2fr) minmax(0, 1.2fr) minmax(0, 1.2fr) 70px 80px 50px", borderBottom: index === rules.length - 1 ? "none" : "1px solid var(--border-soft)" }}
|
||||
>
|
||||
<div style={{ fontWeight: 480, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{rule.name}</div>
|
||||
<div><Pill>{humanize(rule.triggerEvent)}</Pill></div>
|
||||
<div><Pill tone="violet">{humanize(rule.actionType)}</Pill></div>
|
||||
<div className="num" style={{ fontSize: 12 }}>{rule.fireCount}</div>
|
||||
<div>{rule.enabled ? <Pill tone="green">Active</Pill> : <Pill>Paused</Pill>}</div>
|
||||
<div style={{ textAlign: "right" }} onClick={(e) => e.stopPropagation()}>
|
||||
<IconButton icon="x" label="Delete" onClick={() => remove(rule.id)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Dialog title="New automation rule" open={createOpen} submitLabel="Create" onClose={() => setCreateOpen(false)} onSubmit={create}>
|
||||
<Field label="Name"><input className="input" name="name" required /></Field>
|
||||
<Field label="Trigger">
|
||||
<select className="input" name="triggerEvent" defaultValue="task_created">
|
||||
{TRIGGER_EVENTS.map((t) => <option key={t} value={t}>{humanize(t)}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Action">
|
||||
<select className="input" name="actionType" defaultValue="assign_to_department">
|
||||
{ACTION_TYPES.map((a) => <option key={a} value={a}>{humanize(a)}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Priority"><input className="input" name="priority" type="number" defaultValue="100" /></Field>
|
||||
</Dialog>
|
||||
|
||||
{editing && (
|
||||
<Dialog key={editing.id} title={editing.name} open submitLabel="Save" onClose={() => setEditingId(null)} onSubmit={save}>
|
||||
<Field label="Name"><input className="input" name="name" defaultValue={editing.name} required /></Field>
|
||||
<Field label="Trigger">
|
||||
<select className="input" name="triggerEvent" defaultValue={editing.triggerEvent}>
|
||||
{TRIGGER_EVENTS.map((t) => <option key={t} value={t}>{humanize(t)}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Action">
|
||||
<select className="input" name="actionType" defaultValue={editing.actionType}>
|
||||
{ACTION_TYPES.map((a) => <option key={a} value={a}>{humanize(a)}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Priority"><input className="input" name="priority" type="number" defaultValue={editing.priority} /></Field>
|
||||
<Field label="Enabled">
|
||||
<label className="row" style={{ gap: 8 }}>
|
||||
<input type="checkbox" name="enabled" defaultChecked={editing.enabled} />
|
||||
<span style={{ fontSize: 13 }}>Rule is active</span>
|
||||
</label>
|
||||
</Field>
|
||||
{editing.lastFiredAt && <div style={{ fontSize: 12, color: "var(--text-3)" }}>Last fired {formatRelative(editing.lastFiredAt)}</div>}
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function humanize(value: string): string {
|
||||
return value.replace(/_/g, " ");
|
||||
}
|
||||
@@ -2,59 +2,25 @@ import { useMemo, useState } from "react";
|
||||
import { ProvidersPanel } from "./settings/ProvidersPanel";
|
||||
import { SkillsPanel } from "./settings/SkillsPanel";
|
||||
import { WorkspacePanel } from "./settings/WorkspacePanel";
|
||||
import { MembersPanel } from "./settings/MembersPanel";
|
||||
import { BillingPanel } from "./settings/BillingPanel";
|
||||
import { ModelsPanel } from "./settings/ModelsPanel";
|
||||
import { PoliciesPanel } from "./settings/PoliciesPanel";
|
||||
import { OperationsPanel } from "./settings/OperationsPanel";
|
||||
import { SdlcPanel } from "./settings/SdlcPanel";
|
||||
import { MigrationPanel } from "./settings/MigrationPanel";
|
||||
import { CompliancePanel } from "./settings/CompliancePanel";
|
||||
import { NotificationsPanel, ProfilePanel, SecurityPanel } from "./settings/AccountPanels";
|
||||
import type { PageProps } from "./types";
|
||||
import { activeOrgId, requireActiveEmployee, requireActiveOrganization } from "./types";
|
||||
|
||||
type SettingsTab =
|
||||
| "workspace"
|
||||
| "members"
|
||||
| "billing"
|
||||
| "models"
|
||||
| "providers"
|
||||
| "skills"
|
||||
| "policies"
|
||||
| "operations"
|
||||
| "sdlc"
|
||||
| "compliance"
|
||||
| "migration"
|
||||
| "profile"
|
||||
| "notifications"
|
||||
| "security";
|
||||
type SettingsTab = "workspace" | "billing" | "providers" | "skills" | "migration";
|
||||
|
||||
interface NavSpec {
|
||||
id: SettingsTab;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const WORKSPACE_GROUP: NavSpec[] = [
|
||||
const TABS: NavSpec[] = [
|
||||
{ id: "workspace", label: "Workspace" },
|
||||
{ id: "members", label: "Members" },
|
||||
{ id: "billing", label: "Billing" },
|
||||
];
|
||||
const AGENTS_GROUP: NavSpec[] = [
|
||||
{ id: "models", label: "Models" },
|
||||
{ id: "providers", label: "Providers" },
|
||||
{ id: "skills", label: "Skills" },
|
||||
{ id: "policies", label: "Policies" },
|
||||
{ id: "operations", label: "Operations" },
|
||||
{ id: "sdlc", label: "SDLC" },
|
||||
{ id: "compliance", label: "Compliance" },
|
||||
{ id: "migration", label: "Migration" },
|
||||
];
|
||||
const ACCOUNT_GROUP: NavSpec[] = [
|
||||
{ id: "profile", label: "Profile" },
|
||||
{ id: "notifications", label: "Notifications" },
|
||||
{ id: "security", label: "Security" },
|
||||
];
|
||||
|
||||
export function SettingsPage({ snapshot, actions }: PageProps) {
|
||||
const orgId = activeOrgId(snapshot);
|
||||
@@ -62,7 +28,6 @@ export function SettingsPage({ snapshot, actions }: PageProps) {
|
||||
const employee = requireActiveEmployee(snapshot);
|
||||
const [tab, setTab] = useState<SettingsTab>("providers");
|
||||
|
||||
const employees = useMemo(() => snapshot.employees.filter((item) => item.orgId === orgId), [snapshot.employees, orgId]);
|
||||
const agents = useMemo(() => snapshot.agents.filter((item) => item.orgId === orgId), [snapshot.agents, orgId]);
|
||||
const providers = useMemo(() => snapshot.providers.filter((item) => item.orgId === orgId), [snapshot.providers, orgId]);
|
||||
const providerAuthProfiles = useMemo(() => snapshot.providerAuthProfiles.filter((item) => item.orgId === orgId), [snapshot.providerAuthProfiles, orgId]);
|
||||
@@ -77,36 +42,10 @@ export function SettingsPage({ snapshot, actions }: PageProps) {
|
||||
return snapshot.skillTests.filter((test) => revisionIds.has(test.revisionId));
|
||||
}, [snapshot.skillTests, skillRevisions]);
|
||||
const skillBindings = useMemo(() => snapshot.skillBindings.filter((item) => item.orgId === orgId), [snapshot.skillBindings, orgId]);
|
||||
const policies = useMemo(() => snapshot.policyRules.filter((item) => item.orgId === orgId), [snapshot.policyRules, orgId]);
|
||||
const workspaces = useMemo(() => snapshot.workspaces.filter((item) => item.orgId === orgId), [snapshot.workspaces, orgId]);
|
||||
const chatSpaces = useMemo(() => snapshot.chatSpaces.filter((item) => item.orgId === orgId), [snapshot.chatSpaces, orgId]);
|
||||
const chatThreads = useMemo(() => snapshot.chatThreads.filter((item) => item.orgId === orgId), [snapshot.chatThreads, orgId]);
|
||||
const files = useMemo(() => snapshot.files.filter((item) => item.orgId === orgId), [snapshot.files, orgId]);
|
||||
const providerRequests = useMemo(() => snapshot.providerRequests.filter((item) => item.orgId === orgId), [snapshot.providerRequests, orgId]);
|
||||
const auditEvents = useMemo(() => snapshot.auditEvents.filter((event) => event.orgId === orgId), [snapshot.auditEvents, orgId]);
|
||||
const migrationReports = useMemo(() => snapshot.migrationReports.filter((report) => report.orgId === orgId), [snapshot.migrationReports, orgId]);
|
||||
const complianceExports = useMemo(() => snapshot.complianceExports.filter((item) => item.orgId === orgId), [snapshot.complianceExports, orgId]);
|
||||
const legalHolds = useMemo(() => snapshot.legalHolds.filter((item) => item.orgId === orgId), [snapshot.legalHolds, orgId]);
|
||||
const retentionFreezes = useMemo(() => snapshot.retentionFreezes.filter((item) => item.orgId === orgId), [snapshot.retentionFreezes, orgId]);
|
||||
const employeeIdentities = useMemo(() => snapshot.employeeIdentities.filter((item) => item.orgId === orgId), [snapshot.employeeIdentities, orgId]);
|
||||
const serviceIdentities = useMemo(() => snapshot.serviceIdentities.filter((item) => item.orgId === orgId), [snapshot.serviceIdentities, orgId]);
|
||||
const roleGrants = useMemo(() => snapshot.roleGrants.filter((item) => item.orgId === orgId), [snapshot.roleGrants, orgId]);
|
||||
const orgDepartments = useMemo(() => snapshot.orgDepartments.filter((item) => item.orgId === orgId), [snapshot.orgDepartments, orgId]);
|
||||
const hiringPolicies = useMemo(() => snapshot.hiringPolicies.filter((item) => item.orgId === orgId), [snapshot.hiringPolicies, orgId]);
|
||||
const approvalRoutingRules = useMemo(() => snapshot.approvalRoutingRules.filter((item) => item.orgId === orgId), [snapshot.approvalRoutingRules, orgId]);
|
||||
const approvalAssignments = useMemo(() => snapshot.approvalAssignments.filter((item) => item.orgId === orgId), [snapshot.approvalAssignments, orgId]);
|
||||
const featureFlags = useMemo(() => snapshot.featureFlags.filter((item) => item.orgId === orgId), [snapshot.featureFlags, orgId]);
|
||||
const sloTargets = useMemo(() => snapshot.sloTargets.filter((item) => item.orgId === orgId), [snapshot.sloTargets, orgId]);
|
||||
const operationalEvents = useMemo(() => snapshot.operationalEvents.filter((item) => item.orgId === orgId), [snapshot.operationalEvents, orgId]);
|
||||
const chaosDrills = useMemo(() => snapshot.chaosDrills.filter((item) => item.orgId === orgId), [snapshot.chaosDrills, orgId]);
|
||||
const chaosDrillRuns = useMemo(() => snapshot.chaosDrillRuns.filter((item) => item.orgId === orgId), [snapshot.chaosDrillRuns, orgId]);
|
||||
const sdlcReviewAgents = useMemo(() => snapshot.sdlcReviewAgents.filter((item) => item.orgId === orgId), [snapshot.sdlcReviewAgents, orgId]);
|
||||
const sdlcReviewRuns = useMemo(() => snapshot.sdlcReviewRuns.filter((item) => item.orgId === orgId), [snapshot.sdlcReviewRuns, orgId]);
|
||||
const evalSuites = useMemo(() => snapshot.evalSuites.filter((item) => item.orgId === orgId), [snapshot.evalSuites, orgId]);
|
||||
const evalCases = useMemo(() => snapshot.evalCases.filter((item) => item.orgId === orgId), [snapshot.evalCases, orgId]);
|
||||
const evalRuns = useMemo(() => snapshot.evalRuns.filter((item) => item.orgId === orgId), [snapshot.evalRuns, orgId]);
|
||||
const prBotGates = useMemo(() => snapshot.prBotGates.filter((item) => item.orgId === orgId), [snapshot.prBotGates, orgId]);
|
||||
const auditReplayRuns = useMemo(() => snapshot.auditReplayRuns.filter((item) => item.orgId === orgId), [snapshot.auditReplayRuns, orgId]);
|
||||
const migrationReports = useMemo(() => snapshot.migrationReports.filter((report) => report.orgId === orgId), [snapshot.migrationReports, orgId]);
|
||||
const rollbackSnapshots = useMemo(() => snapshot.rollbackSnapshots.filter((item) => item.orgId === orgId), [snapshot.rollbackSnapshots, orgId]);
|
||||
|
||||
return (
|
||||
@@ -114,27 +53,13 @@ export function SettingsPage({ snapshot, actions }: PageProps) {
|
||||
<aside className="secondary-pane">
|
||||
<div style={{ fontSize: 13, fontWeight: 600, padding: "4px 8px 14px" }}>Settings</div>
|
||||
<div className="pane-scroll">
|
||||
<NavGroup items={WORKSPACE_GROUP} active={tab} onChange={setTab} />
|
||||
<NavGroupLabel>Agents</NavGroupLabel>
|
||||
<NavGroup items={AGENTS_GROUP} active={tab} onChange={setTab} />
|
||||
<NavGroupLabel>Account</NavGroupLabel>
|
||||
<NavGroup items={ACCOUNT_GROUP} active={tab} onChange={setTab} />
|
||||
<NavGroup items={TABS} active={tab} onChange={setTab} />
|
||||
</div>
|
||||
</aside>
|
||||
<div className="main" style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="content">
|
||||
{tab === "workspace" && <WorkspacePanel organization={organization} workspaces={workspaces} projects={snapshot.projects.filter((p) => p.orgId === orgId)} orgId={orgId} actions={actions} />}
|
||||
{tab === "members" && (
|
||||
<MembersPanel
|
||||
employees={employees}
|
||||
agents={agents}
|
||||
employeeIdentities={employeeIdentities}
|
||||
serviceIdentities={serviceIdentities}
|
||||
roleGrants={roleGrants}
|
||||
/>
|
||||
)}
|
||||
{tab === "billing" && <BillingPanel agents={agents} providers={providers} providerRequests={providerRequests} hiringPolicies={hiringPolicies} />}
|
||||
{tab === "models" && <ModelsPanel providers={providers} agents={agents} />}
|
||||
{tab === "providers" && (
|
||||
<ProvidersPanel
|
||||
orgId={orgId}
|
||||
@@ -160,58 +85,6 @@ export function SettingsPage({ snapshot, actions }: PageProps) {
|
||||
actions={actions}
|
||||
/>
|
||||
)}
|
||||
{tab === "policies" && (
|
||||
<PoliciesPanel
|
||||
policies={policies}
|
||||
agents={agents}
|
||||
employees={employees}
|
||||
orgDepartments={orgDepartments}
|
||||
hiringPolicies={hiringPolicies}
|
||||
approvalRoutingRules={approvalRoutingRules}
|
||||
approvalAssignments={approvalAssignments}
|
||||
/>
|
||||
)}
|
||||
{tab === "operations" && (
|
||||
<OperationsPanel
|
||||
orgId={orgId}
|
||||
featureFlags={featureFlags}
|
||||
sloTargets={sloTargets}
|
||||
operationalEvents={operationalEvents}
|
||||
chaosDrills={chaosDrills}
|
||||
chaosDrillRuns={chaosDrillRuns}
|
||||
actions={actions}
|
||||
/>
|
||||
)}
|
||||
{tab === "sdlc" && (
|
||||
<SdlcPanel
|
||||
orgId={orgId}
|
||||
reviewAgents={sdlcReviewAgents}
|
||||
reviewRuns={sdlcReviewRuns}
|
||||
evalSuites={evalSuites}
|
||||
evalCases={evalCases}
|
||||
evalRuns={evalRuns}
|
||||
prBotGates={prBotGates}
|
||||
migrationReports={migrationReports}
|
||||
policies={policies}
|
||||
actions={actions}
|
||||
/>
|
||||
)}
|
||||
{tab === "compliance" && (
|
||||
<CompliancePanel
|
||||
orgId={orgId}
|
||||
employeeId={employee.id}
|
||||
employees={employees}
|
||||
agents={agents}
|
||||
chatSpaces={chatSpaces}
|
||||
chatThreads={chatThreads}
|
||||
files={files}
|
||||
exports={complianceExports}
|
||||
legalHolds={legalHolds}
|
||||
retentionFreezes={retentionFreezes}
|
||||
auditReplayRuns={auditReplayRuns}
|
||||
actions={actions}
|
||||
/>
|
||||
)}
|
||||
{tab === "migration" && (
|
||||
<MigrationPanel
|
||||
orgId={orgId}
|
||||
@@ -222,9 +95,6 @@ export function SettingsPage({ snapshot, actions }: PageProps) {
|
||||
actions={actions}
|
||||
/>
|
||||
)}
|
||||
{tab === "profile" && <ProfilePanel employee={employee} />}
|
||||
{tab === "notifications" && <NotificationsPanel events={auditEvents} employee={employee} />}
|
||||
{tab === "security" && <SecurityPanel events={auditEvents} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -256,9 +126,3 @@ function NavGroup({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavGroupLabel({ children }: { children: string }) {
|
||||
return (
|
||||
<div className="section-label" style={{ margin: "18px 0 6px", padding: "0 8px" }}>{children}</div>
|
||||
);
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { Avatar, Pill } from "../../shared/ui";
|
||||
import type { AuditEventRecord, EmployeeRecord } from "../../../../shared/domain";
|
||||
import { formatRelative, humanizeToken } from "../types";
|
||||
|
||||
export function ProfilePanel({ employee }: { employee: EmployeeRecord }) {
|
||||
return (
|
||||
<div className="page">
|
||||
<h1 className="page-h1">Profile</h1>
|
||||
<p className="page-sub">Your identity in this workspace.</p>
|
||||
<div className="card" style={{ padding: 18, display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
<div className="row" style={{ gap: 14 }}>
|
||||
<Avatar name={employee.displayName} size="xl" />
|
||||
<div>
|
||||
<div style={{ fontSize: 18, fontWeight: 620 }}>{employee.displayName}</div>
|
||||
<div style={{ color: "var(--text-3)" }}>{employee.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<span className="card-label" style={{ width: 140 }}>Joined</span>
|
||||
<span className="mono" style={{ color: "var(--text-2)" }}>{new Date(employee.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className="row">
|
||||
<span className="card-label" style={{ width: 140 }}>Employee ID</span>
|
||||
<span className="mono" style={{ color: "var(--text-2)" }}>{employee.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotificationsPanel({ events, employee }: { events: AuditEventRecord[]; employee: EmployeeRecord }) {
|
||||
const visible = events.filter((event) => event.actorId === employee.id || event.actorType === "system").slice(0, 40);
|
||||
return (
|
||||
<div className="page">
|
||||
<h1 className="page-h1">Notifications</h1>
|
||||
<p className="page-sub">Real audit events flagged for you.</p>
|
||||
{visible.length > 0 && (
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
{visible.map((event, index) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="row"
|
||||
style={{ padding: "12px 18px", borderBottom: index === visible.length - 1 ? "none" : "1px solid var(--border-soft)" }}
|
||||
>
|
||||
<Pill>{event.entityType.replaceAll("_", " ")}</Pill>
|
||||
<span style={{ flex: 1, fontWeight: 480, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{humanizeToken(event.action)}</span>
|
||||
<span style={{ color: "var(--text-3)", fontSize: 12 }}>{formatRelative(event.createdAt)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SecurityPanel({ events }: { events: AuditEventRecord[] }) {
|
||||
const security = events.filter((event) => isSecurityEvent(event.action, event.entityType)).slice(0, 50);
|
||||
return (
|
||||
<div className="page">
|
||||
<h1 className="page-h1">Security</h1>
|
||||
<p className="page-sub">Recent security-relevant audit events.</p>
|
||||
{security.length > 0 && (
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
{security.map((event, index) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="row"
|
||||
style={{ padding: "12px 18px", borderBottom: index === security.length - 1 ? "none" : "1px solid var(--border-soft)" }}
|
||||
>
|
||||
<Pill tone="amber">{event.entityType.replaceAll("_", " ")}</Pill>
|
||||
<span style={{ flex: 1, fontWeight: 480, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{humanizeToken(event.action)}</span>
|
||||
<span style={{ color: "var(--text-3)", fontSize: 12 }}>{formatRelative(event.createdAt)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isSecurityEvent(action: string, entityType: string): boolean {
|
||||
return (
|
||||
action.includes("credential") ||
|
||||
action.includes("login") ||
|
||||
entityType.includes("approval") ||
|
||||
entityType.includes("policy")
|
||||
);
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
import { Children, type FormEvent, type ReactNode } from "react";
|
||||
import { Button, Icon, Pill } from "../../shared/ui";
|
||||
import type {
|
||||
AgentRecord,
|
||||
AuditReplayRunRecord,
|
||||
ChatSpaceRecord,
|
||||
ChatThreadRecord,
|
||||
ComplianceExportRecord,
|
||||
EmployeeRecord,
|
||||
FileObjectRecord,
|
||||
LegalHoldRecord,
|
||||
RetentionFreezeRecord,
|
||||
} from "../../../../shared/domain";
|
||||
import type { SnapshotActions } from "../../app/useSnapshot";
|
||||
import { formatRelative } from "../types";
|
||||
|
||||
export function CompliancePanel({
|
||||
orgId,
|
||||
employeeId,
|
||||
employees,
|
||||
agents,
|
||||
chatSpaces,
|
||||
chatThreads,
|
||||
files,
|
||||
exports,
|
||||
legalHolds,
|
||||
retentionFreezes,
|
||||
auditReplayRuns,
|
||||
actions,
|
||||
}: {
|
||||
orgId: string;
|
||||
employeeId: string;
|
||||
employees: EmployeeRecord[];
|
||||
agents: AgentRecord[];
|
||||
chatSpaces: ChatSpaceRecord[];
|
||||
chatThreads: ChatThreadRecord[];
|
||||
files: FileObjectRecord[];
|
||||
exports: ComplianceExportRecord[];
|
||||
legalHolds: LegalHoldRecord[];
|
||||
retentionFreezes: RetentionFreezeRecord[];
|
||||
auditReplayRuns: AuditReplayRunRecord[];
|
||||
actions: SnapshotActions;
|
||||
}) {
|
||||
const submitExport = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
const [subjectType, subjectId] = splitPair(String(form.get("subject") ?? ""));
|
||||
void actions.run(() => window.agentCompany.createComplianceExport({
|
||||
orgId,
|
||||
subjectType: subjectType as "employee" | "agent",
|
||||
subjectId,
|
||||
exportType: String(form.get("exportType")) as "gdpr_export" | "gdpr_delete",
|
||||
requestedByEmployeeId: employeeId,
|
||||
}));
|
||||
};
|
||||
|
||||
const submitHold = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
const [entityType, entityId] = splitPair(String(form.get("entity") ?? ""));
|
||||
const reason = String(form.get("reason") ?? "").trim();
|
||||
void actions.run(() => window.agentCompany.createLegalHold({
|
||||
orgId,
|
||||
entityType: entityType as "organization" | "employee" | "agent" | "chat_thread" | "file_object",
|
||||
entityId,
|
||||
reason,
|
||||
createdByEmployeeId: employeeId,
|
||||
})).then((result) => { if (result) event.currentTarget.reset(); });
|
||||
};
|
||||
|
||||
const submitFreeze = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
const [scopeType, scopeId] = splitPair(String(form.get("scope") ?? ""));
|
||||
const reason = String(form.get("reason") ?? "").trim();
|
||||
void actions.run(() => window.agentCompany.createRetentionFreeze({
|
||||
orgId,
|
||||
scopeType: scopeType as "organization" | "chat_space" | "file_object",
|
||||
scopeId,
|
||||
reason,
|
||||
createdByEmployeeId: employeeId,
|
||||
})).then((result) => { if (result) event.currentTarget.reset(); });
|
||||
};
|
||||
|
||||
const submitReplay = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
void actions.run(() => window.agentCompany.createAuditReplay({
|
||||
orgId,
|
||||
threadId: String(form.get("threadId") ?? ""),
|
||||
createdByEmployeeId: employeeId,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h1 className="page-h1">Compliance</h1>
|
||||
<p className="page-sub">GDPR exports, legal holds, and retention freezes.</p>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1fr)", gap: 16 }}>
|
||||
<section className="card">
|
||||
<SectionTitle icon="docs" title="Export" />
|
||||
<form onSubmit={submitExport} style={{ display: "grid", gap: 10 }}>
|
||||
<LabeledSelect label="Type" name="exportType">
|
||||
<option value="gdpr_export">GDPR export</option>
|
||||
<option value="gdpr_delete">GDPR delete</option>
|
||||
</LabeledSelect>
|
||||
<LabeledSelect label="Subject" name="subject">
|
||||
{employees.map((employee) => <option key={employee.id} value={`employee:${employee.id}`}>Employee · {employee.displayName}</option>)}
|
||||
{agents.map((agent) => <option key={agent.id} value={`agent:${agent.id}`}>Agent · {agent.name}</option>)}
|
||||
</LabeledSelect>
|
||||
<div className="row" style={{ justifyContent: "flex-end" }}>
|
||||
<Button tone="primary" size="small" type="submit"><Icon name="upload" size={12} /> Create</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<SectionTitle icon="shield" title="Legal hold" />
|
||||
<form onSubmit={submitHold} style={{ display: "grid", gap: 10 }}>
|
||||
<LabeledSelect label="Target" name="entity">
|
||||
<option value={`organization:${orgId}`}>Organization</option>
|
||||
{employees.map((employee) => <option key={employee.id} value={`employee:${employee.id}`}>Employee · {employee.displayName}</option>)}
|
||||
{agents.map((agent) => <option key={agent.id} value={`agent:${agent.id}`}>Agent · {agent.name}</option>)}
|
||||
{chatThreads.map((thread) => <option key={thread.id} value={`chat_thread:${thread.id}`}>Thread · {thread.title}</option>)}
|
||||
{files.map((file) => <option key={file.id} value={`file_object:${file.id}`}>File · {file.name}</option>)}
|
||||
</LabeledSelect>
|
||||
<LabeledInput label="Reason" name="reason" />
|
||||
<div className="row" style={{ justifyContent: "flex-end" }}>
|
||||
<Button tone="primary" size="small" type="submit"><Icon name="plus" size={12} /> Hold</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<SectionTitle icon="pause" title="Retention freeze" />
|
||||
<form onSubmit={submitFreeze} style={{ display: "grid", gap: 10 }}>
|
||||
<LabeledSelect label="Scope" name="scope">
|
||||
<option value={`organization:${orgId}`}>Organization</option>
|
||||
{chatSpaces.map((space) => <option key={space.id} value={`chat_space:${space.id}`}>Space · {space.name}</option>)}
|
||||
{files.map((file) => <option key={file.id} value={`file_object:${file.id}`}>File · {file.name}</option>)}
|
||||
</LabeledSelect>
|
||||
<LabeledInput label="Reason" name="reason" />
|
||||
<div className="row" style={{ justifyContent: "flex-end" }}>
|
||||
<Button tone="primary" size="small" type="submit"><Icon name="pause" size={12} /> Freeze</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<SectionTitle icon="refresh" title="Thread replay" />
|
||||
<form onSubmit={submitReplay} style={{ display: "grid", gap: 10 }}>
|
||||
<LabeledSelect label="Thread" name="threadId">
|
||||
{chatThreads.map((thread) => <option key={thread.id} value={thread.id}>{thread.title}</option>)}
|
||||
</LabeledSelect>
|
||||
<div className="row" style={{ justifyContent: "flex-end" }}>
|
||||
<Button tone="primary" size="small" type="submit" disabled={chatThreads.length === 0}><Icon name="refresh" size={12} /> Run</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<SectionTitle icon="inbox" title="Export history" />
|
||||
<RecordList emptyLabel="0 exports">
|
||||
{exports.map((item) => (
|
||||
<RecordRow
|
||||
key={item.id}
|
||||
title={`${item.exportType.replaceAll("_", " ")} · ${item.subjectType}`}
|
||||
meta={`${item.state} · ${formatRelative(item.createdAt)} · ${item.payloadJson.length} bytes`}
|
||||
/>
|
||||
))}
|
||||
</RecordList>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="card" style={{ marginTop: 16 }}>
|
||||
<SectionTitle icon="list" title="Replay runs" />
|
||||
<RecordList emptyLabel="0 replay runs">
|
||||
{auditReplayRuns.map((run) => (
|
||||
<RecordRow
|
||||
key={run.id}
|
||||
title={threadTitle(chatThreads, run.threadId)}
|
||||
meta={`${run.eventCount} events · ${run.eventDigest.slice(0, 12)} · ${formatRelative(run.createdAt)}`}
|
||||
/>
|
||||
))}
|
||||
</RecordList>
|
||||
</section>
|
||||
|
||||
<section className="card" style={{ marginTop: 16 }}>
|
||||
<SectionTitle icon="approve" title="Active controls" />
|
||||
<RecordList emptyLabel="0 active controls">
|
||||
{legalHolds.map((hold) => (
|
||||
<RecordRow
|
||||
key={hold.id}
|
||||
title={`Legal hold · ${hold.entityType}`}
|
||||
meta={`${hold.reason} · ${formatRelative(hold.createdAt)}`}
|
||||
active={hold.active}
|
||||
actionLabel={hold.active ? "Release" : "Activate"}
|
||||
onAction={() => void actions.run(() => window.agentCompany.setLegalHoldState({ orgId, legalHoldId: hold.id, active: !hold.active, employeeId }))}
|
||||
/>
|
||||
))}
|
||||
{retentionFreezes.map((freeze) => (
|
||||
<RecordRow
|
||||
key={freeze.id}
|
||||
title={`Retention freeze · ${freeze.scopeType}`}
|
||||
meta={`${freeze.reason} · ${formatRelative(freeze.createdAt)}`}
|
||||
active={freeze.active}
|
||||
actionLabel={freeze.active ? "Release" : "Activate"}
|
||||
onAction={() => void actions.run(() => window.agentCompany.setRetentionFreezeState({ orgId, retentionFreezeId: freeze.id, active: !freeze.active, employeeId }))}
|
||||
/>
|
||||
))}
|
||||
</RecordList>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionTitle({ icon, title }: { icon: "approve" | "docs" | "inbox" | "list" | "pause" | "refresh" | "shield"; title: string }) {
|
||||
return (
|
||||
<div className="row" style={{ marginBottom: 12 }}>
|
||||
<Icon name={icon} size={14} color="var(--text-3)" />
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>{title}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LabeledSelect({ label, name, children }: { label: string; name: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="field" style={{ margin: 0 }}>
|
||||
<span>{label}</span>
|
||||
<select className="input" name={name} required>{children}</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function LabeledInput({ label, name }: { label: string; name: string }) {
|
||||
return (
|
||||
<label className="field" style={{ margin: 0 }}>
|
||||
<span>{label}</span>
|
||||
<input className="input" name={name} required />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordList({ emptyLabel, children }: { emptyLabel: string; children: ReactNode }) {
|
||||
const items = Children.toArray(children).filter(Boolean);
|
||||
return items.length > 0 ? <div style={{ display: "grid", gap: 8 }}>{items}</div> : <Pill>{emptyLabel}</Pill>;
|
||||
}
|
||||
|
||||
function RecordRow({
|
||||
title,
|
||||
meta,
|
||||
active,
|
||||
actionLabel,
|
||||
onAction,
|
||||
}: {
|
||||
title: string;
|
||||
meta: string;
|
||||
active?: boolean;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="row" style={{ borderTop: "1px solid var(--border-soft)", paddingTop: 8, alignItems: "center" }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12.5, fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{title}</div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-3)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{meta}</div>
|
||||
</div>
|
||||
{active !== undefined && <Pill tone={active ? "red" : undefined}>{active ? "active" : "released"}</Pill>}
|
||||
{onAction && actionLabel && <Button size="small" onClick={onAction}>{actionLabel}</Button>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function splitPair(value: string): [string, string] {
|
||||
const index = value.indexOf(":");
|
||||
if (index < 1) throw new Error("Compliance selector value is invalid.");
|
||||
return [value.slice(0, index), value.slice(index + 1)];
|
||||
}
|
||||
|
||||
function threadTitle(threads: ChatThreadRecord[], threadId: string): string {
|
||||
const thread = threads.find((item) => item.id === threadId);
|
||||
if (!thread) throw new Error("Audit replay thread is missing from snapshot.");
|
||||
return thread.title;
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import { Avatar, Pill } from "../../shared/ui";
|
||||
import type { AgentRecord, EmployeeIdentityRecord, EmployeeRecord, RoleGrantRecord, ServiceIdentityRecord } from "../../../../shared/domain";
|
||||
import { money } from "../types";
|
||||
|
||||
export function MembersPanel({
|
||||
employees,
|
||||
agents,
|
||||
employeeIdentities,
|
||||
serviceIdentities,
|
||||
roleGrants,
|
||||
}: {
|
||||
employees: EmployeeRecord[];
|
||||
agents: AgentRecord[];
|
||||
employeeIdentities: EmployeeIdentityRecord[];
|
||||
serviceIdentities: ServiceIdentityRecord[];
|
||||
roleGrants: RoleGrantRecord[];
|
||||
}) {
|
||||
const total = employees.length + agents.length;
|
||||
return (
|
||||
<div className="page">
|
||||
<h1 className="page-h1">Members</h1>
|
||||
<p className="page-sub">
|
||||
{total} {total === 1 ? "member" : "members"} · {employees.length} {employees.length === 1 ? "human" : "humans"} · {agents.length} {agents.length === 1 ? "agent" : "agents"}
|
||||
</p>
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
<div
|
||||
className="table-row head"
|
||||
style={{ gridTemplateColumns: "minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr)" }}
|
||||
>
|
||||
<div>Member</div><div>Type</div><div>Department</div><div>Budget</div>
|
||||
</div>
|
||||
{employees.map((employee) => (
|
||||
<div
|
||||
key={employee.id}
|
||||
className="table-row"
|
||||
style={{ gridTemplateColumns: "minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr)" }}
|
||||
>
|
||||
<div className="row" style={{ gap: 10 }}>
|
||||
<Avatar name={employee.displayName} size="sm" />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 500 }}>{employee.displayName}</div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-3)" }}>{employee.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div><Pill>Human</Pill></div>
|
||||
<div style={{ color: "var(--text-4)" }}>—</div>
|
||||
<div style={{ color: "var(--text-4)" }}>—</div>
|
||||
</div>
|
||||
))}
|
||||
{agents.map((agent, index) => (
|
||||
<div
|
||||
key={agent.id}
|
||||
className="table-row"
|
||||
style={{
|
||||
gridTemplateColumns: "minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr)",
|
||||
borderBottom: index === agents.length - 1 ? "none" : undefined,
|
||||
}}
|
||||
>
|
||||
<div className="row" style={{ gap: 10 }}>
|
||||
<Avatar name={agent.name} size="sm" bot />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 500 }}>{agent.name}</div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-3)" }}>{agent.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div><Pill tone="violet" dot>Agent</Pill></div>
|
||||
<div style={{ color: "var(--text-2)" }}>{agent.department}</div>
|
||||
<div className="num mono" style={{ color: "var(--text-2)" }}>{money(agent.budgetCents)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="section-label" style={{ margin: "24px 0 10px" }}>Identity & Access</div>
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
<div
|
||||
className="table-row head"
|
||||
style={{ gridTemplateColumns: "minmax(0, 1.6fr) minmax(0, 1.2fr) minmax(0, 1.8fr)" }}
|
||||
>
|
||||
<div>Principal</div><div>Identity source</div><div>Roles</div>
|
||||
</div>
|
||||
{employees.map((employee, index) => (
|
||||
<AccessRow
|
||||
key={employee.id}
|
||||
name={employee.displayName}
|
||||
detail={employee.role}
|
||||
source={identitySource(employee.id, employeeIdentities)}
|
||||
roles={principalRoles("employee", employee.id, roleGrants)}
|
||||
bot={false}
|
||||
last={agents.length === 0 && index === employees.length - 1}
|
||||
/>
|
||||
))}
|
||||
{agents.map((agent, index) => {
|
||||
const serviceIdentity = serviceIdentities.find((identity) => identity.agentId === agent.id);
|
||||
return (
|
||||
<AccessRow
|
||||
key={agent.id}
|
||||
name={agent.name}
|
||||
detail={agent.department}
|
||||
source={serviceIdentity ? "Service identity" : "Agent"}
|
||||
roles={serviceIdentity ? principalRoles("service_identity", serviceIdentity.id, roleGrants) : []}
|
||||
bot
|
||||
last={index === agents.length - 1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccessRow({
|
||||
name,
|
||||
detail,
|
||||
source,
|
||||
roles,
|
||||
bot,
|
||||
last,
|
||||
}: {
|
||||
name: string;
|
||||
detail: string;
|
||||
source: string;
|
||||
roles: string[];
|
||||
bot: boolean;
|
||||
last: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="table-row"
|
||||
style={{
|
||||
gridTemplateColumns: "minmax(0, 1.6fr) minmax(0, 1.2fr) minmax(0, 1.8fr)",
|
||||
borderBottom: last ? "none" : "1px solid var(--border-soft)",
|
||||
}}
|
||||
>
|
||||
<div className="row" style={{ gap: 10, minWidth: 0 }}>
|
||||
<Avatar name={name} size="sm" bot={bot} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{name}</div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-3)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{detail}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ color: "var(--text-2)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{source}</div>
|
||||
<div className="row" style={{ gap: 6, flexWrap: "wrap" }}>
|
||||
{roles.map((role) => <Pill key={role}>{role}</Pill>)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function identitySource(employeeId: string, identities: EmployeeIdentityRecord[]): string {
|
||||
const identity = identities.find((item) => item.employeeId === employeeId && item.active);
|
||||
if (!identity) return "Unlinked";
|
||||
if (identity.source === "sso") return "SSO";
|
||||
if (identity.source === "scim") return "SCIM";
|
||||
return "Bootstrap";
|
||||
}
|
||||
|
||||
function principalRoles(principalType: RoleGrantRecord["principalType"], principalId: string, grants: RoleGrantRecord[]): string[] {
|
||||
return grants
|
||||
.filter((grant) => grant.principalType === principalType && grant.principalId === principalId)
|
||||
.map((grant) => grant.roleKey.replaceAll("_", " "))
|
||||
.sort();
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { Pill } from "../../shared/ui";
|
||||
import type { AgentRecord, ProviderRecord } from "../../../../shared/domain";
|
||||
|
||||
export function ModelsPanel({
|
||||
providers,
|
||||
agents,
|
||||
}: {
|
||||
providers: ProviderRecord[];
|
||||
agents: AgentRecord[];
|
||||
}) {
|
||||
const rows = collectModels(providers, agents);
|
||||
return (
|
||||
<div className="page">
|
||||
<h1 className="page-h1">Models</h1>
|
||||
<p className="page-sub">Models that agents reference, derived from connected providers and agent assignments.</p>
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
<div
|
||||
className="table-row head"
|
||||
style={{ gridTemplateColumns: "minmax(0, 1.4fr) minmax(0, 1.6fr) minmax(0, 1fr) minmax(0, 1fr)" }}
|
||||
>
|
||||
<div>Provider</div><div>Model</div><div>Agents</div><div>Source</div>
|
||||
</div>
|
||||
{rows.map((row, index) => (
|
||||
<div
|
||||
key={`${row.providerId}-${row.model}`}
|
||||
className="table-row"
|
||||
style={{ gridTemplateColumns: "minmax(0, 1.4fr) minmax(0, 1.6fr) minmax(0, 1fr) minmax(0, 1fr)", borderBottom: index === rows.length - 1 ? "none" : "1px solid var(--border-soft)" }}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 540 }}>{row.providerLabel}</div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-3)" }}>{row.authType}</div>
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 12, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{row.model}</div>
|
||||
<div className="num mono">{row.agentCount}</div>
|
||||
<div>{row.source === "default" ? <Pill>Provider default</Pill> : <Pill tone="violet">Agent assignment</Pill>}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModelRow {
|
||||
providerId: string;
|
||||
providerLabel: string;
|
||||
authType: string;
|
||||
model: string;
|
||||
agentCount: number;
|
||||
source: "default" | "agent";
|
||||
}
|
||||
|
||||
function collectModels(providers: ProviderRecord[], agents: AgentRecord[]): ModelRow[] {
|
||||
const map = new Map<string, ModelRow>();
|
||||
for (const provider of providers) {
|
||||
if (!provider.enabled || !provider.model) continue;
|
||||
const key = `${provider.id}::${provider.model}`;
|
||||
map.set(key, {
|
||||
providerId: provider.id,
|
||||
providerLabel: provider.label,
|
||||
authType: humanAuth(provider.authType),
|
||||
model: provider.model,
|
||||
agentCount: 0,
|
||||
source: "default",
|
||||
});
|
||||
}
|
||||
for (const agent of agents) {
|
||||
if (!agent.modelProviderId) continue;
|
||||
const provider = providers.find((item) => item.id === agent.modelProviderId);
|
||||
if (!provider) continue;
|
||||
const effectiveModel = agent.modelId ?? provider.model;
|
||||
if (!effectiveModel) continue;
|
||||
const key = `${provider.id}::${effectiveModel}`;
|
||||
const existing = map.get(key);
|
||||
if (existing) {
|
||||
existing.agentCount += 1;
|
||||
continue;
|
||||
}
|
||||
map.set(key, {
|
||||
providerId: provider.id,
|
||||
providerLabel: provider.label,
|
||||
authType: humanAuth(provider.authType),
|
||||
model: effectiveModel,
|
||||
agentCount: 1,
|
||||
source: "agent",
|
||||
});
|
||||
}
|
||||
return [...map.values()].sort((a, b) => a.providerLabel.localeCompare(b.providerLabel));
|
||||
}
|
||||
|
||||
function humanAuth(authType: string): string {
|
||||
if (authType === "api_key") return "API key";
|
||||
if (authType === "oauth_pkce") return "OAuth";
|
||||
if (authType === "account_subscription") return "Account";
|
||||
if (authType === "cloud") return "Cloud";
|
||||
return authType;
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
import { Button, Pill } from "../../shared/ui";
|
||||
import type {
|
||||
ChaosDrillRecord,
|
||||
ChaosDrillRunRecord,
|
||||
FeatureFlagRecord,
|
||||
OperationalEventRecord,
|
||||
SloTargetRecord,
|
||||
} from "../../../../shared/domain";
|
||||
import type { SnapshotActions } from "../../app/useSnapshot";
|
||||
import { formatRelative } from "../types";
|
||||
|
||||
export function OperationsPanel({
|
||||
orgId,
|
||||
featureFlags,
|
||||
sloTargets,
|
||||
operationalEvents,
|
||||
chaosDrills,
|
||||
chaosDrillRuns,
|
||||
actions,
|
||||
}: {
|
||||
orgId: string;
|
||||
featureFlags: FeatureFlagRecord[];
|
||||
sloTargets: SloTargetRecord[];
|
||||
operationalEvents: OperationalEventRecord[];
|
||||
chaosDrills: ChaosDrillRecord[];
|
||||
chaosDrillRuns: ChaosDrillRunRecord[];
|
||||
actions: SnapshotActions;
|
||||
}) {
|
||||
return (
|
||||
<div className="page">
|
||||
<h1 className="page-h1">Operations</h1>
|
||||
<p className="page-sub">{featureFlags.length} flags / {sloTargets.length} SLOs / {chaosDrills.length} drills.</p>
|
||||
<FeatureFlagsTable orgId={orgId} flags={featureFlags} actions={actions} />
|
||||
<SloTable targets={sloTargets} events={operationalEvents} />
|
||||
<ChaosDrillsTable orgId={orgId} drills={chaosDrills} runs={chaosDrillRuns} actions={actions} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureFlagsTable({
|
||||
orgId,
|
||||
flags,
|
||||
actions,
|
||||
}: {
|
||||
orgId: string;
|
||||
flags: FeatureFlagRecord[];
|
||||
actions: SnapshotActions;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="section-label" style={{ marginBottom: 10 }}>Feature flags</div>
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden", marginBottom: 24 }}>
|
||||
<div className="table-row head" style={{ gridTemplateColumns: "minmax(0, 1.4fr) 112px minmax(0, 1fr)" }}>
|
||||
<div>Flag</div><div>State</div><div>Updated</div>
|
||||
</div>
|
||||
{flags.map((flag, index) => (
|
||||
<div
|
||||
key={flag.id}
|
||||
className="table-row"
|
||||
style={{
|
||||
gridTemplateColumns: "minmax(0, 1.4fr) 112px minmax(0, 1fr)",
|
||||
borderBottom: index === flags.length - 1 ? "none" : "1px solid var(--border-soft)",
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{flag.label}</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>{flag.key}</div>
|
||||
</div>
|
||||
<label className="row" style={{ gap: 8, fontSize: 12, color: "var(--text-2)" }}>
|
||||
<input
|
||||
aria-label={`${flag.label} enabled`}
|
||||
type="checkbox"
|
||||
checked={flag.enabled}
|
||||
onChange={(event) => {
|
||||
void actions.run(() => window.agentCompany.setFeatureFlag({ orgId, flagId: flag.id, enabled: event.target.checked }));
|
||||
}}
|
||||
/>
|
||||
{flag.enabled ? "On" : "Off"}
|
||||
</label>
|
||||
<div className="mono" style={{ fontSize: 12, color: "var(--text-3)" }}>{formatRelative(flag.updatedAt)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SloTable({
|
||||
targets,
|
||||
events,
|
||||
}: {
|
||||
targets: SloTargetRecord[];
|
||||
events: OperationalEventRecord[];
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="section-label" style={{ marginBottom: 10 }}>SLO targets</div>
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden", marginBottom: 24 }}>
|
||||
<div className="table-row head" style={{ gridTemplateColumns: "minmax(0, 1.4fr) 96px 112px 96px" }}>
|
||||
<div>Signal</div><div>Target</div><div>Current</div><div>Samples</div>
|
||||
</div>
|
||||
{targets.map((target, index) => {
|
||||
const samples = events.filter((event) => event.signalKey === target.signalKey && withinWindow(event.createdAt, target.windowMinutes));
|
||||
const successes = samples.filter((event) => event.success).length;
|
||||
return (
|
||||
<div
|
||||
key={target.id}
|
||||
className="table-row"
|
||||
style={{
|
||||
gridTemplateColumns: "minmax(0, 1.4fr) 96px 112px 96px",
|
||||
borderBottom: index === targets.length - 1 ? "none" : "1px solid var(--border-soft)",
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{target.label}</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>{target.signalKey}</div>
|
||||
</div>
|
||||
<div className="num mono">{formatBasisPoints(target.targetBasisPoints)}</div>
|
||||
<div><Pill tone={samples.length > 0 && currentBasisPoints(successes, samples.length) < target.targetBasisPoints ? "amber" : undefined}>{formatCurrent(successes, samples.length)}</Pill></div>
|
||||
<div className="num mono">{samples.length}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ChaosDrillsTable({
|
||||
orgId,
|
||||
drills,
|
||||
runs,
|
||||
actions,
|
||||
}: {
|
||||
orgId: string;
|
||||
drills: ChaosDrillRecord[];
|
||||
runs: ChaosDrillRunRecord[];
|
||||
actions: SnapshotActions;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="section-label" style={{ marginBottom: 10 }}>Chaos drills</div>
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
<div className="table-row head" style={{ gridTemplateColumns: "minmax(0, 1.4fr) 116px 96px 82px" }}>
|
||||
<div>Drill</div><div>Last run</div><div>Result</div><div>Action</div>
|
||||
</div>
|
||||
{drills.map((drill, index) => {
|
||||
const run = runs.find((item) => item.drillId === drill.id) ?? null;
|
||||
return (
|
||||
<div
|
||||
key={drill.id}
|
||||
className="table-row"
|
||||
style={{
|
||||
gridTemplateColumns: "minmax(0, 1.4fr) 116px 96px 82px",
|
||||
borderBottom: index === drills.length - 1 ? "none" : "1px solid var(--border-soft)",
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{drill.label}</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>{drill.scenarioKey}</div>
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 12, color: "var(--text-3)" }}>{run ? formatRelative(run.startedAt) : "0 runs"}</div>
|
||||
<div><Pill tone={run?.state === "failed" ? "red" : run?.state === "passed" ? "green" : undefined}>{run?.state ?? "Ready"}</Pill></div>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={!drill.enabled}
|
||||
onClick={() => void actions.run(() => window.agentCompany.runChaosDrill({ orgId, drillId: drill.id }))}
|
||||
>
|
||||
Run
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function withinWindow(iso: string, windowMinutes: number): boolean {
|
||||
const createdAt = new Date(iso).getTime();
|
||||
if (Number.isNaN(createdAt)) return false;
|
||||
return Date.now() - createdAt <= windowMinutes * 60_000;
|
||||
}
|
||||
|
||||
function currentBasisPoints(successes: number, samples: number): number {
|
||||
if (samples === 0) return 0;
|
||||
return Math.round((successes / samples) * 10000);
|
||||
}
|
||||
|
||||
function formatCurrent(successes: number, samples: number): string {
|
||||
if (samples === 0) return "0 / 0";
|
||||
return formatBasisPoints(currentBasisPoints(successes, samples));
|
||||
}
|
||||
|
||||
function formatBasisPoints(value: number): string {
|
||||
return `${(value / 100).toFixed(value % 100 === 0 ? 0 : 2)}%`;
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
import { Pill } from "../../shared/ui";
|
||||
import type {
|
||||
AgentRecord,
|
||||
ApprovalAssignmentRecord,
|
||||
ApprovalRoutingRuleRecord,
|
||||
EmployeeRecord,
|
||||
HiringPolicyRecord,
|
||||
OrgDepartmentRecord,
|
||||
PolicyRuleRecord,
|
||||
} from "../../../../shared/domain";
|
||||
import { money } from "../types";
|
||||
|
||||
export function PoliciesPanel({
|
||||
policies,
|
||||
agents,
|
||||
employees,
|
||||
orgDepartments,
|
||||
hiringPolicies,
|
||||
approvalRoutingRules,
|
||||
approvalAssignments,
|
||||
}: {
|
||||
policies: PolicyRuleRecord[];
|
||||
agents: AgentRecord[];
|
||||
employees: EmployeeRecord[];
|
||||
orgDepartments: OrgDepartmentRecord[];
|
||||
hiringPolicies: HiringPolicyRecord[];
|
||||
approvalRoutingRules: ApprovalRoutingRuleRecord[];
|
||||
approvalAssignments: ApprovalAssignmentRecord[];
|
||||
}) {
|
||||
return (
|
||||
<div className="page">
|
||||
<h1 className="page-h1">Policies</h1>
|
||||
<p className="page-sub">Policy rules evaluated for actions across the workspace.</p>
|
||||
<div className="section-label" style={{ marginBottom: 10 }}>Rules</div>
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
<div
|
||||
className="table-row head"
|
||||
style={{ gridTemplateColumns: "minmax(0, 1.2fr) minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 1fr)" }}
|
||||
>
|
||||
<div>Action</div><div>Effect</div><div>Risk ceiling</div><div>Subject</div>
|
||||
</div>
|
||||
{policies.map((policy, index) => (
|
||||
<div
|
||||
key={policy.id}
|
||||
className="table-row"
|
||||
style={{ gridTemplateColumns: "minmax(0, 1.2fr) minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 1fr)", borderBottom: index === policies.length - 1 ? "none" : "1px solid var(--border-soft)" }}
|
||||
>
|
||||
<div style={{ fontWeight: 540 }}>{policy.action.replaceAll("_", " ")}</div>
|
||||
<div><Pill>{policy.effect.replaceAll("_", " ")}</Pill></div>
|
||||
<div>
|
||||
<Pill tone={policy.riskCeiling === "high" ? "red" : policy.riskCeiling === "medium" ? "amber" : undefined}>
|
||||
{policy.riskCeiling}
|
||||
</Pill>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-3)" }}>{policy.subjectType}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="section-label" style={{ margin: "24px 0 10px" }}>Hiring policies</div>
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
<div
|
||||
className="table-row head"
|
||||
style={{ gridTemplateColumns: "minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1.4fr) minmax(0, 1fr)" }}
|
||||
>
|
||||
<div>Department</div><div>Headcount</div><div>Budget cap</div><div>Review</div>
|
||||
</div>
|
||||
{hiringPolicies.map((policy, index) => (
|
||||
<HiringPolicyRow
|
||||
key={policy.id}
|
||||
policy={policy}
|
||||
department={orgDepartments.find((item) => item.name === policy.departmentName) ?? null}
|
||||
agents={agents}
|
||||
last={index === hiringPolicies.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="section-label" style={{ margin: "24px 0 10px" }}>Review routing</div>
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
<div
|
||||
className="table-row head"
|
||||
style={{ gridTemplateColumns: "minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 1.4fr) minmax(0, 1fr)" }}
|
||||
>
|
||||
<div>Kind</div><div>Min risk</div><div>Route</div><div>Assigned</div>
|
||||
</div>
|
||||
{approvalRoutingRules.map((rule, index) => (
|
||||
<RoutingRuleRow
|
||||
key={rule.id}
|
||||
rule={rule}
|
||||
employees={employees}
|
||||
agents={agents}
|
||||
assignmentCount={approvalAssignments.filter((assignment) => assignment.routingRuleId === rule.id).length}
|
||||
last={index === approvalRoutingRules.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HiringPolicyRow({
|
||||
policy,
|
||||
department,
|
||||
agents,
|
||||
last,
|
||||
}: {
|
||||
policy: HiringPolicyRecord;
|
||||
department: OrgDepartmentRecord | null;
|
||||
agents: AgentRecord[];
|
||||
last: boolean;
|
||||
}) {
|
||||
if (!department) throw new Error(`Department governance is missing for ${policy.departmentName}.`);
|
||||
const roster = agents.filter((agent) => agent.department === policy.departmentName && agent.lifecycle === "active");
|
||||
const budgetCents = roster.reduce((sum, agent) => sum + agent.budgetCents, 0);
|
||||
return (
|
||||
<div
|
||||
className="table-row"
|
||||
style={{
|
||||
gridTemplateColumns: "minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1.4fr) minmax(0, 1fr)",
|
||||
borderBottom: last ? "none" : "1px solid var(--border-soft)",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 540 }}>{policy.departmentName}</div>
|
||||
<div className="num mono">{roster.length} / {department.headcountLimit}</div>
|
||||
<div className="num mono">{money(budgetCents)} / {money(policy.monthlyBudgetLimitCents)}</div>
|
||||
<div><Pill>{money(policy.requireApprovalAboveCents)}</Pill></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RoutingRuleRow({
|
||||
rule,
|
||||
employees,
|
||||
agents,
|
||||
assignmentCount,
|
||||
last,
|
||||
}: {
|
||||
rule: ApprovalRoutingRuleRecord;
|
||||
employees: EmployeeRecord[];
|
||||
agents: AgentRecord[];
|
||||
assignmentCount: number;
|
||||
last: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="table-row"
|
||||
style={{
|
||||
gridTemplateColumns: "minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 1.4fr) minmax(0, 1fr)",
|
||||
borderBottom: last ? "none" : "1px solid var(--border-soft)",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 540 }}>{rule.kind}</div>
|
||||
<div><Pill tone={rule.minimumRisk === "high" ? "red" : rule.minimumRisk === "medium" ? "amber" : undefined}>{rule.minimumRisk}</Pill></div>
|
||||
<div style={{ color: "var(--text-2)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{routeLabel(rule, employees, agents)}
|
||||
</div>
|
||||
<div className="num mono">{assignmentCount}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function routeLabel(rule: ApprovalRoutingRuleRecord, employees: EmployeeRecord[], agents: AgentRecord[]): string {
|
||||
if (rule.routeToType === "board") return "Board";
|
||||
if (rule.routeToType === "employee") {
|
||||
const employee = employees.find((item) => item.id === rule.routeToId);
|
||||
if (!employee) throw new Error("Approval route employee is missing.");
|
||||
return employee.displayName;
|
||||
}
|
||||
const agent = agents.find((item) => item.id === rule.routeToId);
|
||||
if (!agent) throw new Error("Approval route agent is missing.");
|
||||
return agent.name;
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
import { Button, Pill } from "../../shared/ui";
|
||||
import type {
|
||||
EvalCaseRecord,
|
||||
EvalRunRecord,
|
||||
EvalSuiteRecord,
|
||||
MigrationReportRecord,
|
||||
PolicyRuleRecord,
|
||||
PrBotGateRecord,
|
||||
SdlcReviewAgentRecord,
|
||||
SdlcReviewRunRecord,
|
||||
SdlcReviewTargetType,
|
||||
} from "../../../../shared/domain";
|
||||
import { SDLC_REVIEW_AGENT_KEY, SDLC_REVIEW_TARGET_TYPE } from "../../../../shared/domain";
|
||||
import type { SnapshotActions } from "../../app/useSnapshot";
|
||||
import { formatRelative } from "../types";
|
||||
|
||||
export function SdlcPanel({
|
||||
orgId,
|
||||
reviewAgents,
|
||||
reviewRuns,
|
||||
evalSuites,
|
||||
evalCases,
|
||||
evalRuns,
|
||||
prBotGates,
|
||||
migrationReports,
|
||||
policies,
|
||||
actions,
|
||||
}: {
|
||||
orgId: string;
|
||||
reviewAgents: SdlcReviewAgentRecord[];
|
||||
reviewRuns: SdlcReviewRunRecord[];
|
||||
evalSuites: EvalSuiteRecord[];
|
||||
evalCases: EvalCaseRecord[];
|
||||
evalRuns: EvalRunRecord[];
|
||||
prBotGates: PrBotGateRecord[];
|
||||
migrationReports: MigrationReportRecord[];
|
||||
policies: PolicyRuleRecord[];
|
||||
actions: SnapshotActions;
|
||||
}) {
|
||||
return (
|
||||
<div className="page">
|
||||
<h1 className="page-h1">SDLC</h1>
|
||||
<p className="page-sub">{reviewAgents.length} agents / {evalSuites.length} suites / {prBotGates.length} gates.</p>
|
||||
<ReviewAgentsTable
|
||||
orgId={orgId}
|
||||
agents={reviewAgents}
|
||||
runs={reviewRuns}
|
||||
migrationReports={migrationReports}
|
||||
policies={policies}
|
||||
actions={actions}
|
||||
/>
|
||||
<EvalSuitesTable orgId={orgId} suites={evalSuites} cases={evalCases} runs={evalRuns} actions={actions} />
|
||||
<PrBotGatesTable orgId={orgId} gates={prBotGates} actions={actions} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewAgentsTable({
|
||||
orgId,
|
||||
agents,
|
||||
runs,
|
||||
migrationReports,
|
||||
policies,
|
||||
actions,
|
||||
}: {
|
||||
orgId: string;
|
||||
agents: SdlcReviewAgentRecord[];
|
||||
runs: SdlcReviewRunRecord[];
|
||||
migrationReports: MigrationReportRecord[];
|
||||
policies: PolicyRuleRecord[];
|
||||
actions: SnapshotActions;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="section-label" style={{ marginBottom: 10 }}>Review agents</div>
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden", marginBottom: 24 }}>
|
||||
<div className="table-row head" style={{ gridTemplateColumns: "minmax(0, 1.4fr) minmax(0, 1fr) 96px 82px" }}>
|
||||
<div>Agent</div><div>Target</div><div>Last run</div><div>Action</div>
|
||||
</div>
|
||||
{agents.map((agent, index) => {
|
||||
const target = resolveReviewTarget(agent, orgId, migrationReports, policies);
|
||||
const run = runs.find((item) => item.reviewAgentId === agent.id) ?? null;
|
||||
return (
|
||||
<div
|
||||
key={agent.id}
|
||||
className="table-row"
|
||||
style={{
|
||||
gridTemplateColumns: "minmax(0, 1.4fr) minmax(0, 1fr) 96px 82px",
|
||||
borderBottom: index === agents.length - 1 ? "none" : "1px solid var(--border-soft)",
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{agent.label}</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>{agent.specialty}</div>
|
||||
</div>
|
||||
<div><Pill>{target.label}</Pill></div>
|
||||
<div className="mono" style={{ fontSize: 12, color: "var(--text-3)" }}>{run ? `${run.state} · ${formatRelative(run.createdAt)}` : "0 runs"}</div>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={!agent.enabled || !target.ready}
|
||||
onClick={() => void actions.run(() => window.agentCompany.runSdlcReview({
|
||||
orgId,
|
||||
reviewAgentId: agent.id,
|
||||
targetType: target.type,
|
||||
targetRef: target.ref,
|
||||
}))}
|
||||
>
|
||||
Run
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EvalSuitesTable({
|
||||
orgId,
|
||||
suites,
|
||||
cases,
|
||||
runs,
|
||||
actions,
|
||||
}: {
|
||||
orgId: string;
|
||||
suites: EvalSuiteRecord[];
|
||||
cases: EvalCaseRecord[];
|
||||
runs: EvalRunRecord[];
|
||||
actions: SnapshotActions;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="section-label" style={{ marginBottom: 10 }}>Eval suites</div>
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden", marginBottom: 24 }}>
|
||||
<div className="table-row head" style={{ gridTemplateColumns: "minmax(0, 1.4fr) 90px 96px 82px" }}>
|
||||
<div>Suite</div><div>Cases</div><div>Last run</div><div>Action</div>
|
||||
</div>
|
||||
{suites.map((suite, index) => {
|
||||
const run = runs.find((item) => item.suiteId === suite.id) ?? null;
|
||||
const caseCount = cases.filter((item) => item.suiteId === suite.id).length;
|
||||
return (
|
||||
<div
|
||||
key={suite.id}
|
||||
className="table-row"
|
||||
style={{
|
||||
gridTemplateColumns: "minmax(0, 1.4fr) 90px 96px 82px",
|
||||
borderBottom: index === suites.length - 1 ? "none" : "1px solid var(--border-soft)",
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{suite.label}</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>{suite.scope}</div>
|
||||
</div>
|
||||
<div className="num mono">{caseCount}</div>
|
||||
<div className="mono" style={{ fontSize: 12, color: "var(--text-3)" }}>{run ? `${run.state} · ${formatRelative(run.createdAt)}` : "0 runs"}</div>
|
||||
<Button size="small" onClick={() => void actions.run(() => window.agentCompany.runEvalSuite({ orgId, suiteId: suite.id }))}>
|
||||
Run
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PrBotGatesTable({
|
||||
orgId,
|
||||
gates,
|
||||
actions,
|
||||
}: {
|
||||
orgId: string;
|
||||
gates: PrBotGateRecord[];
|
||||
actions: SnapshotActions;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="section-label" style={{ marginBottom: 10 }}>PR bot gates</div>
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
<div className="table-row head" style={{ gridTemplateColumns: "minmax(0, 1.4fr) 112px minmax(0, 1fr)" }}>
|
||||
<div>Gate</div><div>State</div><div>Updated</div>
|
||||
</div>
|
||||
{gates.map((gate, index) => (
|
||||
<div
|
||||
key={gate.id}
|
||||
className="table-row"
|
||||
style={{
|
||||
gridTemplateColumns: "minmax(0, 1.4fr) 112px minmax(0, 1fr)",
|
||||
borderBottom: index === gates.length - 1 ? "none" : "1px solid var(--border-soft)",
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{gate.label}</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>{gate.gateType}</div>
|
||||
</div>
|
||||
<label className="row" style={{ gap: 8, fontSize: 12, color: "var(--text-2)" }}>
|
||||
<input
|
||||
aria-label={`${gate.label} enabled`}
|
||||
type="checkbox"
|
||||
checked={gate.enabled}
|
||||
onChange={(event) => {
|
||||
void actions.run(() => window.agentCompany.setPrBotGate({ orgId, gateId: gate.id, enabled: event.target.checked }));
|
||||
}}
|
||||
/>
|
||||
{gate.enabled ? "On" : "Off"}
|
||||
</label>
|
||||
<div className="mono" style={{ fontSize: 12, color: "var(--text-3)" }}>{formatRelative(gate.updatedAt)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveReviewTarget(
|
||||
agent: SdlcReviewAgentRecord,
|
||||
orgId: string,
|
||||
migrationReports: MigrationReportRecord[],
|
||||
policies: PolicyRuleRecord[],
|
||||
): { type: SdlcReviewTargetType; ref: string; label: string; ready: boolean } {
|
||||
if (agent.key === SDLC_REVIEW_AGENT_KEY.MIGRATION_REVIEWER) {
|
||||
const report = migrationReports[0] ?? null;
|
||||
return {
|
||||
type: SDLC_REVIEW_TARGET_TYPE.MIGRATION,
|
||||
ref: report?.id ?? orgId,
|
||||
label: report ? "migration" : "migration report",
|
||||
ready: Boolean(report),
|
||||
};
|
||||
}
|
||||
if (agent.key === SDLC_REVIEW_AGENT_KEY.POLICY_REVIEWER) {
|
||||
const policy = policies[0] ?? null;
|
||||
return {
|
||||
type: SDLC_REVIEW_TARGET_TYPE.POLICY,
|
||||
ref: policy?.id ?? orgId,
|
||||
label: policy ? "policy" : "policy rule",
|
||||
ready: Boolean(policy),
|
||||
};
|
||||
}
|
||||
return { type: SDLC_REVIEW_TARGET_TYPE.WORKSPACE, ref: orgId, label: "workspace", ready: true };
|
||||
}
|
||||
@@ -14,7 +14,6 @@ export type RouteId =
|
||||
| "files"
|
||||
| "docs"
|
||||
| "knowledge"
|
||||
| "automation"
|
||||
| "inbox"
|
||||
| "runs"
|
||||
| "meetings"
|
||||
@@ -35,18 +34,17 @@ const FLUSH_ROUTES = new Set<RouteId>(["chat", "settings", "files", "social", "r
|
||||
|
||||
const primaryNav: NavSpec[] = [
|
||||
{ id: "home", label: "Home", icon: "home" },
|
||||
{ id: "team", label: "Team", icon: "team" },
|
||||
{ id: "inbox", label: "Inbox", icon: "approve" },
|
||||
{ id: "work", label: "Work", icon: "work" },
|
||||
{ id: "approvals", label: "Approvals", icon: "approve" },
|
||||
{ id: "team", label: "Team", icon: "team" },
|
||||
{ id: "chat", label: "Messages", icon: "chat" },
|
||||
{ id: "files", label: "Files", icon: "docs" },
|
||||
{ id: "inbox", label: "Inbox", icon: "approve" },
|
||||
{ id: "docs", label: "Documents", icon: "file" },
|
||||
{ id: "knowledge", label: "Knowledge", icon: "sparkle" },
|
||||
{ id: "runs", label: "Runs", icon: "send" },
|
||||
{ id: "automation", label: "Automation", icon: "bot" },
|
||||
{ id: "meetings", label: "Meetings", icon: "team" },
|
||||
{ id: "sprints", label: "Sprints", icon: "work" },
|
||||
{ id: "approvals", label: "Approvals", icon: "approve" },
|
||||
{ id: "social", label: "Social", icon: "social" },
|
||||
];
|
||||
|
||||
@@ -134,7 +132,6 @@ export function Shell({
|
||||
<div className="sidebar-foot">
|
||||
<Avatar name={employee.displayName} size="sm" />
|
||||
<span className="sidebar-foot-name">{employee.displayName}</span>
|
||||
<Icon name="bell" size={14} color="var(--text-3)" />
|
||||
</div>
|
||||
</aside>
|
||||
<main className="main">
|
||||
|
||||
@@ -76,29 +76,6 @@ test("onboards, hires, and creates work in the Electron shell", async () => {
|
||||
expect(hiredAgent.workspaceId).toBe(hiredAgent.primaryWorkspaceId);
|
||||
expect(hiredAgent.heartbeatEnabled).toBe(true);
|
||||
expect(hiredAgent.heartbeatIntervalSec).toBe(120);
|
||||
await page.getByRole("button", { name: "Settings" }).click();
|
||||
await page.getByRole("button", { name: "Members" }).click();
|
||||
await expect(page.getByText("Identity & Access")).toBeVisible();
|
||||
await expect(page.getByText("org admin").first()).toBeVisible();
|
||||
await expect(page.getByText("scoped agent").first()).toBeVisible();
|
||||
await page.getByRole("button", { name: "Policies" }).click();
|
||||
await expect(page.getByText("Hiring policies")).toBeVisible();
|
||||
await expect(page.getByText("Review routing")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Operations", exact: true }).click();
|
||||
await expect(page.getByText("Feature flags")).toBeVisible();
|
||||
await expect(page.getByText("SLO targets")).toBeVisible();
|
||||
await expect(page.getByText("Chaos drills").first()).toBeVisible();
|
||||
await page.getByRole("button", { name: "Run" }).first().click();
|
||||
await expect(page.getByText("passed").first()).toBeVisible();
|
||||
await page.getByRole("button", { name: "SDLC", exact: true }).click();
|
||||
await expect(page.getByText("Review agents", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Eval suites", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("PR bot gates", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Run" }).first().click();
|
||||
await expect(page.getByText("passed").first()).toBeVisible();
|
||||
await page.getByRole("button", { name: "Run" }).nth(3).click();
|
||||
await expect(page.getByText(/passed ·/).first()).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Work", exact: true }).click();
|
||||
await page.getByRole("button", { name: "New task" }).click();
|
||||
await page.getByLabel("Title").fill("Production deploy");
|
||||
@@ -177,14 +154,6 @@ test("onboards, hires, and creates work in the Electron shell", async () => {
|
||||
await expect(page.getByText("Never run").first()).toBeVisible();
|
||||
await page.getByRole("button", { name: "Run tests" }).click();
|
||||
await expect(page.getByText("1/1 passed").first()).toBeVisible();
|
||||
await page.getByRole("button", { name: "Compliance" }).click();
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
await expect(page.getByText("gdpr export · employee").first()).toBeVisible();
|
||||
await expect(page.getByText("Thread replay")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Run" }).click();
|
||||
await expect(page.getByText("Replay runs", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/\d+ events ·/).first()).toBeVisible();
|
||||
|
||||
const legacyPath = createLegacyDatabase();
|
||||
await page.getByRole("button", { name: "Migration" }).click();
|
||||
await page.getByRole("button", { name: "Report" }).click();
|
||||
@@ -238,7 +207,7 @@ async function verifyPagesDoNotOverflow(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: nav, exact: true }).click();
|
||||
await expectPageToFit(page);
|
||||
}
|
||||
for (const tab of ["Workspace", "Members", "Billing", "Models", "Providers", "Skills", "Policies", "Operations", "SDLC", "Compliance", "Migration", "Profile", "Notifications", "Security"]) {
|
||||
for (const tab of ["Workspace", "Billing", "Providers", "Skills", "Migration"]) {
|
||||
await page.getByRole("button", { name: "Settings" }).click();
|
||||
await page.getByRole("button", { name: tab, exact: true }).click();
|
||||
await expectPageToFit(page);
|
||||
|
||||
@@ -49,7 +49,7 @@ test("captures every page for visual review", async () => {
|
||||
await go(page, "Social", outDir, "16-social");
|
||||
await go(page, "Settings", outDir, "17-settings");
|
||||
|
||||
for (const tab of ["Workspace", "Members", "Billing", "Models", "Skills", "Policies", "Compliance", "Migration", "Profile", "Notifications", "Security"]) {
|
||||
for (const tab of ["Workspace", "Billing", "Skills", "Migration"]) {
|
||||
await page.getByRole("button", { name: "Settings" }).click();
|
||||
await page.getByRole("button", { name: tab }).first().click();
|
||||
await snap(page, outDir, `settings-${tab.toLowerCase()}`);
|
||||
|
||||
Reference in new issue
Block a user