Refine launcher admin access workflows
This commit is contained in:
parent
3b006303a2
commit
ec021d1a95
|
|
@ -41,6 +41,7 @@ const engineWorkflowAccessRequestStatuses = new Set(["new", "approved", "rejecte
|
||||||
const engineWorkflowAccessRequestTypes = new Set(["workflow", "service_role"]);
|
const engineWorkflowAccessRequestTypes = new Set(["workflow", "service_role"]);
|
||||||
const engineWorkflowRoles = new Set(["viewer", "editor", "admin"]);
|
const engineWorkflowRoles = new Set(["viewer", "editor", "admin"]);
|
||||||
const engineServiceRoles = new Set(["viewer", "member"]);
|
const engineServiceRoles = new Set(["viewer", "member"]);
|
||||||
|
const syncStates = new Set(["synced", "pending", "error", "disabled"]);
|
||||||
const publicPoolClientId = "client_public_pool";
|
const publicPoolClientId = "client_public_pool";
|
||||||
const protectedLauncherUserIds = new Set(["user_root"]);
|
const protectedLauncherUserIds = new Set(["user_root"]);
|
||||||
const engineAuthentikGroups = ["nodedc_admin", "nodedc_editor", "nodedc_viewer"];
|
const engineAuthentikGroups = ["nodedc_admin", "nodedc_editor", "nodedc_viewer"];
|
||||||
|
|
@ -2043,6 +2044,42 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
return { syncStatus, data };
|
return { syncStatus, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateSyncStatus(syncId, patch, identity) {
|
||||||
|
const data = readData();
|
||||||
|
const actor = resolveActor(data, identity);
|
||||||
|
const syncStatus = findById(data.syncStatuses, syncId, "sync status");
|
||||||
|
const now = isoNow();
|
||||||
|
|
||||||
|
if ("state" in (patch ?? {})) {
|
||||||
|
syncStatus.state = pickEnum(patch.state, syncStates, syncStatus.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("error" in (patch ?? {})) {
|
||||||
|
syncStatus.error = nullableString(patch.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("objectName" in (patch ?? {})) {
|
||||||
|
syncStatus.objectName = optionalString(patch.objectName, syncStatus.objectName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncStatus.state === "synced") {
|
||||||
|
syncStatus.lastSyncAt = nullableString(patch?.lastSyncAt) ?? now;
|
||||||
|
syncStatus.error = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncStatus.updatedAt = now;
|
||||||
|
addAuditEvent(data, actor, {
|
||||||
|
action: "Статус sync обновлён",
|
||||||
|
objectType: syncStatus.objectType,
|
||||||
|
objectName: syncStatus.objectName,
|
||||||
|
result: syncStatus.state === "error" ? "error" : "success",
|
||||||
|
details: `Target: ${syncStatus.target}; State: ${syncStatus.state}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await writeData(data);
|
||||||
|
return { syncStatus, data };
|
||||||
|
}
|
||||||
|
|
||||||
async function markUserAuthentikProvisioned(userId, provisioning, identity) {
|
async function markUserAuthentikProvisioned(userId, provisioning, identity) {
|
||||||
const data = readData();
|
const data = readData();
|
||||||
const actor = resolveActor(data, identity);
|
const actor = resolveActor(data, identity);
|
||||||
|
|
@ -2191,6 +2228,7 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
replaceData,
|
replaceData,
|
||||||
reorderServices,
|
reorderServices,
|
||||||
retrySync,
|
retrySync,
|
||||||
|
updateSyncStatus,
|
||||||
markUserAuthentikProvisioned,
|
markUserAuthentikProvisioned,
|
||||||
recordTaskManagerProjectMembership,
|
recordTaskManagerProjectMembership,
|
||||||
recordTaskManagerWorkspaceMembership,
|
recordTaskManagerWorkspaceMembership,
|
||||||
|
|
|
||||||
|
|
@ -2196,11 +2196,50 @@ app.post("/api/admin/access/service-modules", requireLauncherAdmin, asyncRoute(a
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.post("/api/admin/sync/:syncId/retry", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
|
app.post("/api/admin/sync/:syncId/retry", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
|
||||||
const result = await controlPlaneStore.retrySync(req.params.syncId, req.nodedcSession.user);
|
const retryResult = await controlPlaneStore.retrySync(req.params.syncId, req.nodedcSession.user);
|
||||||
publishControlPlaneEvent("admin.sync.retry");
|
const affectedUserIds = resolveSyncStatusAffectedUserIds(retryResult.data, retryResult.syncStatus);
|
||||||
res.json(result);
|
|
||||||
|
if (!authentikSyncClient.isConfigured()) {
|
||||||
|
const result = await controlPlaneStore.updateSyncStatus(
|
||||||
|
req.params.syncId,
|
||||||
|
{ state: "error", error: "Authentik API не настроен на сервере launcher." },
|
||||||
|
req.nodedcSession.user
|
||||||
|
);
|
||||||
|
publishControlPlaneEvent("admin.sync.retry.failed");
|
||||||
|
res.json(scopeAdminMutationResult(req, { ...result, ok: false, message: result.syncStatus.error }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const syncResult = affectedUserIds.length
|
||||||
|
? await syncUsersToAuthentik(retryResult.data, affectedUserIds, req.nodedcSession.user)
|
||||||
|
: { data: retryResult.data, userIds: [] };
|
||||||
|
const result = await controlPlaneStore.updateSyncStatus(
|
||||||
|
req.params.syncId,
|
||||||
|
{ state: "synced", error: null },
|
||||||
|
req.nodedcSession.user
|
||||||
|
);
|
||||||
|
|
||||||
|
publishControlPlaneEvent("admin.sync.retry", syncResult.userIds);
|
||||||
|
res.json(scopeAdminMutationResult(req, { ...result, ok: true, syncedUserIds: syncResult.userIds }));
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Не удалось выполнить синхронизацию Authentik";
|
||||||
|
const result = await controlPlaneStore.updateSyncStatus(
|
||||||
|
req.params.syncId,
|
||||||
|
{ state: "error", error: message },
|
||||||
|
req.nodedcSession.user
|
||||||
|
);
|
||||||
|
|
||||||
|
publishControlPlaneEvent("admin.sync.retry.failed");
|
||||||
|
res.json(scopeAdminMutationResult(req, { ...result, ok: false, message }));
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
app.get("/api/admin/sync/authentik/health", requireLauncherAdmin, requireRootLauncherAdmin, (req, res) => {
|
||||||
|
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
|
||||||
|
res.json(buildAuthentikSyncHealth(snapshot.data));
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/api/admin/sync/authentik/plan", requireLauncherAdmin, requireRootLauncherAdmin, (_req, res) => {
|
app.get("/api/admin/sync/authentik/plan", requireLauncherAdmin, requireRootLauncherAdmin, (_req, res) => {
|
||||||
res.json(controlPlaneStore.buildAuthentikSyncPlan());
|
res.json(controlPlaneStore.buildAuthentikSyncPlan());
|
||||||
});
|
});
|
||||||
|
|
@ -2664,6 +2703,116 @@ async function syncUsersToAuthentik(data, userIds, identity) {
|
||||||
return { data: latestData, userIds: uniqueUserIds };
|
return { data: latestData, userIds: uniqueUserIds };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildAuthentikSyncHealth(data) {
|
||||||
|
const rows = data.syncStatuses.filter((syncStatus) => syncStatus.target === "authentik");
|
||||||
|
const errors = rows.filter((syncStatus) => syncStatus.state === "error");
|
||||||
|
const pending = rows.filter((syncStatus) => syncStatus.state === "pending");
|
||||||
|
const freshPending = pending.filter(isFreshPendingSyncStatus);
|
||||||
|
const stalePending = pending.filter((syncStatus) => !isFreshPendingSyncStatus(syncStatus));
|
||||||
|
const state = !authentikSyncClient.isConfigured()
|
||||||
|
? "not_configured"
|
||||||
|
: errors.length > 0
|
||||||
|
? "error"
|
||||||
|
: freshPending.length > 0
|
||||||
|
? "pending"
|
||||||
|
: "ok";
|
||||||
|
|
||||||
|
return {
|
||||||
|
configured: authentikSyncClient.isConfigured(),
|
||||||
|
state,
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
counts: {
|
||||||
|
total: rows.length,
|
||||||
|
synced: rows.filter((syncStatus) => syncStatus.state === "synced").length,
|
||||||
|
pending: pending.length,
|
||||||
|
freshPending: freshPending.length,
|
||||||
|
stalePending: stalePending.length,
|
||||||
|
errors: errors.length,
|
||||||
|
disabled: rows.filter((syncStatus) => syncStatus.state === "disabled").length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFreshPendingSyncStatus(syncStatus) {
|
||||||
|
if (syncStatus.state !== "pending") return false;
|
||||||
|
const timestamp = Date.parse(syncStatus.updatedAt ?? syncStatus.lastSyncAt ?? "");
|
||||||
|
if (!Number.isFinite(timestamp)) return false;
|
||||||
|
return Date.now() - timestamp <= 5 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSyncStatusAffectedUserIds(data, syncStatus) {
|
||||||
|
if (!syncStatus || syncStatus.target !== "authentik") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncStatus.objectType === "user") {
|
||||||
|
return data.users.some((user) => user.id === syncStatus.objectId) ? [syncStatus.objectId] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncStatus.objectType === "group") {
|
||||||
|
return data.groups.find((group) => group.id === syncStatus.objectId)?.memberIds ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncStatus.objectType === "client") {
|
||||||
|
return data.memberships.filter((membership) => membership.clientId === syncStatus.objectId).map((membership) => membership.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncStatus.objectType === "service") {
|
||||||
|
return resolveServiceSyncUserIds(data, syncStatus.objectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncStatus.objectType === "grant") {
|
||||||
|
return resolveGrantSyncUserIds(data, syncStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncStatus.objectType === "invite") {
|
||||||
|
const invite = data.invites.find((candidate) => candidate.id === syncStatus.objectId);
|
||||||
|
return invite ? resolveUserIdsByEmail(data, invite.email) : resolveUserIdsByEmail(data, syncStatus.objectName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveServiceSyncUserIds(data, serviceId) {
|
||||||
|
const userIds = new Set();
|
||||||
|
|
||||||
|
for (const grant of data.grants.filter((candidate) => candidate.serviceId === serviceId && candidate.status === "active")) {
|
||||||
|
for (const userId of resolveGrantTargetUserIds(data, grant.targetType, grant.targetId)) {
|
||||||
|
userIds.add(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const exception of data.exceptions.filter((candidate) => candidate.serviceId === serviceId)) {
|
||||||
|
userIds.add(exception.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...userIds];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveGrantSyncUserIds(data, syncStatus) {
|
||||||
|
const existingGrant = data.grants.find((grant) => grant.id === syncStatus.objectId);
|
||||||
|
|
||||||
|
if (existingGrant) {
|
||||||
|
return resolveGrantTargetUserIds(data, existingGrant.targetType, existingGrant.targetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [serviceId, userId] = String(syncStatus.objectId).split(":");
|
||||||
|
if (serviceId && userId && data.users.some((user) => user.id === userId)) {
|
||||||
|
return [userId];
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolveUserIdsByEmail(data, syncStatus.objectName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveUserIdsByEmail(data, value) {
|
||||||
|
const normalizedValue = String(value ?? "").toLowerCase();
|
||||||
|
if (!normalizedValue) return [];
|
||||||
|
|
||||||
|
return data.users
|
||||||
|
.filter((user) => normalizedValue.includes(String(user.email ?? "").toLowerCase()))
|
||||||
|
.map((user) => user.id);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveGrantTargetUserIds(data, targetType, targetId) {
|
function resolveGrantTargetUserIds(data, targetType, targetId) {
|
||||||
if (targetType === "user") {
|
if (targetType === "user") {
|
||||||
return [targetId];
|
return [targetId];
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,7 @@ export function LauncherApp() {
|
||||||
const [selectedServiceId, setSelectedServiceId] = useState<string | undefined>();
|
const [selectedServiceId, setSelectedServiceId] = useState<string | undefined>();
|
||||||
const [adminOpen, setAdminOpen] = useState(false);
|
const [adminOpen, setAdminOpen] = useState(false);
|
||||||
const [adminMode, setAdminMode] = useState<LauncherAdminMode>("admin");
|
const [adminMode, setAdminMode] = useState<LauncherAdminMode>("admin");
|
||||||
|
const [adminTemporarilyHidden, setAdminTemporarilyHidden] = useState(false);
|
||||||
const [authSession, setAuthSession] = useState<AuthSession | null>(null);
|
const [authSession, setAuthSession] = useState<AuthSession | null>(null);
|
||||||
const [authApps, setAuthApps] = useState<LauncherAuthApp[] | null>(null);
|
const [authApps, setAuthApps] = useState<LauncherAuthApp[] | null>(null);
|
||||||
const [runtimeReady, setRuntimeReady] = useState(false);
|
const [runtimeReady, setRuntimeReady] = useState(false);
|
||||||
|
|
@ -397,6 +398,7 @@ export function LauncherApp() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (runtimeMe.permissions.canOpenAdmin) return;
|
if (runtimeMe.permissions.canOpenAdmin) return;
|
||||||
|
|
||||||
|
setAdminTemporarilyHidden(false);
|
||||||
setAdminOpen(false);
|
setAdminOpen(false);
|
||||||
setAdminMode("admin");
|
setAdminMode("admin");
|
||||||
}, [runtimeMe.permissions.canOpenAdmin]);
|
}, [runtimeMe.permissions.canOpenAdmin]);
|
||||||
|
|
@ -489,6 +491,7 @@ export function LauncherApp() {
|
||||||
const profile = profileOptions.find((option) => option.userId === userId);
|
const profile = profileOptions.find((option) => option.userId === userId);
|
||||||
setActiveProfileId(userId);
|
setActiveProfileId(userId);
|
||||||
setActiveClientId(profile?.defaultClientId ?? activeClientId);
|
setActiveClientId(profile?.defaultClientId ?? activeClientId);
|
||||||
|
setAdminTemporarilyHidden(false);
|
||||||
setAdminOpen(false);
|
setAdminOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -498,7 +501,17 @@ export function LauncherApp() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleServiceSelect(serviceId: string) {
|
function handleServiceSelect(serviceId: string) {
|
||||||
setSelectedServiceId((current) => (current === serviceId ? undefined : serviceId));
|
if (selectedServiceId === serviceId) {
|
||||||
|
setSelectedServiceId(undefined);
|
||||||
|
setAdminTemporarilyHidden(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adminOpen) {
|
||||||
|
setAdminTemporarilyHidden(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedServiceId(serviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleStageStep(direction: "previous" | "next") {
|
function handleStageStep(direction: "previous" | "next") {
|
||||||
|
|
@ -917,20 +930,25 @@ export function LauncherApp() {
|
||||||
profileOptions={profileOptions}
|
profileOptions={profileOptions}
|
||||||
activeProfileId={resolvedProfileId}
|
activeProfileId={resolvedProfileId}
|
||||||
activeClientId={resolvedClientId}
|
activeClientId={resolvedClientId}
|
||||||
adminOpen={adminOpen}
|
adminOpen={adminOpen && !adminTemporarilyHidden}
|
||||||
adminMode={runtimeMe.launcherRole === "root_admin" ? adminMode : "admin"}
|
adminMode={runtimeMe.launcherRole === "root_admin" ? adminMode : "admin"}
|
||||||
onProfileChange={handleProfileChange}
|
onProfileChange={handleProfileChange}
|
||||||
onClientChange={setActiveClientId}
|
onClientChange={setActiveClientId}
|
||||||
onOpenAdmin={() => {
|
onOpenAdmin={() => {
|
||||||
|
setAdminTemporarilyHidden(false);
|
||||||
setAdminMode("admin");
|
setAdminMode("admin");
|
||||||
setAdminOpen((current) => !(current && adminMode === "admin"));
|
setAdminOpen((current) => (adminTemporarilyHidden ? true : !(current && adminMode === "admin")));
|
||||||
}}
|
}}
|
||||||
onOpenPlatform={() => {
|
onOpenPlatform={() => {
|
||||||
if (runtimeMe.launcherRole !== "root_admin") return;
|
if (runtimeMe.launcherRole !== "root_admin") return;
|
||||||
|
setAdminTemporarilyHidden(false);
|
||||||
setAdminMode("platform");
|
setAdminMode("platform");
|
||||||
setAdminOpen((current) => !(current && adminMode === "platform"));
|
setAdminOpen((current) => (adminTemporarilyHidden ? true : !(current && adminMode === "platform")));
|
||||||
|
}}
|
||||||
|
onOpenShowcase={() => {
|
||||||
|
setAdminTemporarilyHidden(false);
|
||||||
|
setAdminOpen(false);
|
||||||
}}
|
}}
|
||||||
onOpenShowcase={() => setAdminOpen(false)}
|
|
||||||
onOpenProfileSettings={() => setProfileSettingsOpen(true)}
|
onOpenProfileSettings={() => setProfileSettingsOpen(true)}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
brandLinkUrl={data.settings.brand.logoLinkUrl}
|
brandLinkUrl={data.settings.brand.logoLinkUrl}
|
||||||
|
|
@ -959,13 +977,20 @@ export function LauncherApp() {
|
||||||
onSelectNext={() => handleStageStep("next")}
|
onSelectNext={() => handleStageStep("next")}
|
||||||
/>
|
/>
|
||||||
{adminOpen && me.permissions.canOpenAdmin ? (
|
{adminOpen && me.permissions.canOpenAdmin ? (
|
||||||
<Suspense fallback={null}>
|
<div
|
||||||
|
className={adminTemporarilyHidden ? "launcher-admin-mount launcher-admin-mount--hidden" : "launcher-admin-mount"}
|
||||||
|
aria-hidden={adminTemporarilyHidden}
|
||||||
|
>
|
||||||
|
<Suspense fallback={null}>
|
||||||
<AdminOverlay
|
<AdminOverlay
|
||||||
data={data}
|
data={data}
|
||||||
me={runtimeMe}
|
me={runtimeMe}
|
||||||
mode={runtimeMe.launcherRole === "root_admin" ? adminMode : "admin"}
|
mode={runtimeMe.launcherRole === "root_admin" ? adminMode : "admin"}
|
||||||
activeClientId={resolvedClientId}
|
activeClientId={resolvedClientId}
|
||||||
onClose={() => setAdminOpen(false)}
|
onClose={() => {
|
||||||
|
setAdminTemporarilyHidden(false);
|
||||||
|
setAdminOpen(false);
|
||||||
|
}}
|
||||||
onSetUserServiceAccess={handleSetUserServiceAccess}
|
onSetUserServiceAccess={handleSetUserServiceAccess}
|
||||||
onCreateInvite={handleCreateInvite}
|
onCreateInvite={handleCreateInvite}
|
||||||
onUpdateInvite={handleUpdateInvite}
|
onUpdateInvite={handleUpdateInvite}
|
||||||
|
|
@ -1006,7 +1031,8 @@ export function LauncherApp() {
|
||||||
onSetTaskManagerProjectMemberRole={handleSetTaskManagerProjectMemberRole}
|
onSetTaskManagerProjectMemberRole={handleSetTaskManagerProjectMemberRole}
|
||||||
onSetServiceModuleEntitlement={handleSetServiceModuleEntitlement}
|
onSetServiceModuleEntitlement={handleSetServiceModuleEntitlement}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{profileSettingsOpen && activeProfileUser ? (
|
{profileSettingsOpen && activeProfileUser ? (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ export interface ControlPlaneSnapshot {
|
||||||
|
|
||||||
export interface ControlPlaneMutationResult {
|
export interface ControlPlaneMutationResult {
|
||||||
data: LauncherData;
|
data: LauncherData;
|
||||||
|
ok?: boolean;
|
||||||
|
message?: string;
|
||||||
provisioning?: {
|
provisioning?: {
|
||||||
authentikUserId: string;
|
authentikUserId: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
|
@ -34,6 +36,23 @@ export interface ControlPlaneMutationResult {
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AuthentikSyncHealthState = "ok" | "pending" | "error" | "not_configured";
|
||||||
|
|
||||||
|
export interface AuthentikSyncHealth {
|
||||||
|
configured: boolean;
|
||||||
|
state: AuthentikSyncHealthState;
|
||||||
|
checkedAt: string;
|
||||||
|
counts: {
|
||||||
|
total: number;
|
||||||
|
synced: number;
|
||||||
|
pending: number;
|
||||||
|
freshPending: number;
|
||||||
|
stalePending: number;
|
||||||
|
errors: number;
|
||||||
|
disabled: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface AccessRequestMutationResult extends ControlPlaneMutationResult {
|
export interface AccessRequestMutationResult extends ControlPlaneMutationResult {
|
||||||
accessRequest: AccessRequest;
|
accessRequest: AccessRequest;
|
||||||
}
|
}
|
||||||
|
|
@ -168,6 +187,10 @@ export async function fetchAdminTaskManagerWorkspaces(): Promise<{ ok: boolean;
|
||||||
return requestJson<{ ok: boolean; workspaces: TaskManagerWorkspaceSummary[] }>("/api/admin/task-manager/workspaces");
|
return requestJson<{ ok: boolean; workspaces: TaskManagerWorkspaceSummary[] }>("/api/admin/task-manager/workspaces");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminAuthentikSyncHealth(): Promise<AuthentikSyncHealth> {
|
||||||
|
return requestJson<AuthentikSyncHealth>("/api/admin/sync/authentik/health");
|
||||||
|
}
|
||||||
|
|
||||||
export async function createAdminClient(payload: Partial<Client>): Promise<ControlPlaneMutationResult> {
|
export async function createAdminClient(payload: Partial<Client>): Promise<ControlPlaneMutationResult> {
|
||||||
return requestJson<ControlPlaneMutationResult>("/api/admin/clients", {
|
return requestJson<ControlPlaneMutationResult>("/api/admin/clients", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
|
||||||
|
|
@ -681,11 +681,19 @@ code {
|
||||||
background: #050506;
|
background: #050506;
|
||||||
}
|
}
|
||||||
|
|
||||||
.launcher-main:has(.admin-panel-layer) .service-stage {
|
.launcher-admin-mount {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-admin-mount--hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-main:has(.launcher-admin-mount:not(.launcher-admin-mount--hidden) .admin-panel-layer) .service-stage {
|
||||||
padding-left: calc(var(--launcher-page-pad) + var(--admin-nav-width) + var(--admin-panel-gap));
|
padding-left: calc(var(--launcher-page-pad) + var(--admin-nav-width) + var(--admin-panel-gap));
|
||||||
}
|
}
|
||||||
|
|
||||||
.launcher-main:has(.admin-panel-layer--content-open) .service-stage {
|
.launcher-main:has(.launcher-admin-mount:not(.launcher-admin-mount--hidden) .admin-panel-layer--content-open) .service-stage {
|
||||||
padding-left: calc(var(--launcher-page-pad) + var(--admin-nav-width) + var(--admin-content-width) + (var(--admin-panel-gap) * 2));
|
padding-left: calc(var(--launcher-page-pad) + var(--admin-nav-width) + var(--admin-content-width) + (var(--admin-panel-gap) * 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -693,13 +701,13 @@ code {
|
||||||
padding-right: calc(var(--launcher-page-pad) + var(--admin-nav-width) + var(--admin-panel-gap));
|
padding-right: calc(var(--launcher-page-pad) + var(--admin-nav-width) + var(--admin-panel-gap));
|
||||||
}
|
}
|
||||||
|
|
||||||
.launcher-main:has(.admin-panel-layer--content-open) .stage-service-overlay,
|
.launcher-main:has(.launcher-admin-mount:not(.launcher-admin-mount--hidden) .admin-panel-layer--content-open) .stage-service-overlay,
|
||||||
.launcher-main:has(.admin-panel-layer--content-open) .stage-video-controls,
|
.launcher-main:has(.launcher-admin-mount:not(.launcher-admin-mount--hidden) .admin-panel-layer--content-open) .stage-video-controls,
|
||||||
.launcher-main:has(.admin-panel-layer--content-open) .stage-timeline-strip {
|
.launcher-main:has(.launcher-admin-mount:not(.launcher-admin-mount--hidden) .admin-panel-layer--content-open) .stage-timeline-strip {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.launcher-main:has(.admin-panel-layer--fullscreen) .service-rail {
|
.launcher-main:has(.launcher-admin-mount:not(.launcher-admin-mount--hidden) .admin-panel-layer--fullscreen) .service-rail {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transform: translateY(calc(var(--launcher-rail-height) + var(--launcher-rail-bottom) + var(--launcher-stage-rail-gap)));
|
transform: translateY(calc(var(--launcher-rail-height) + var(--launcher-rail-bottom) + var(--launcher-stage-rail-gap)));
|
||||||
|
|
@ -724,7 +732,7 @@ code {
|
||||||
background 220ms ease;
|
background 220ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.launcher-main:has(.admin-panel-layer--fullscreen) .service-stage {
|
.launcher-main:has(.launcher-admin-mount:not(.launcher-admin-mount--hidden) .admin-panel-layer--fullscreen) .service-stage {
|
||||||
transform: translateX(calc(100vw + var(--launcher-page-pad)));
|
transform: translateX(calc(100vw + var(--launcher-page-pad)));
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
@ -1953,6 +1961,14 @@ code {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-panel-content__body:has(> .access-layout) {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-panel-content__body > .access-layout {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-panel-content__body:has(.service-content-modal-layer) {
|
.admin-panel-content__body:has(.service-content-modal-layer) {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
@ -2127,11 +2143,17 @@ code {
|
||||||
.admin-header {
|
.admin-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: space-between;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
min-height: var(--admin-control-ring);
|
min-height: var(--admin-control-ring);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-header__filters {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-header__actions,
|
.admin-header__actions,
|
||||||
.table-toolbar,
|
.table-toolbar,
|
||||||
.access-actions {
|
.access-actions {
|
||||||
|
|
@ -2141,6 +2163,39 @@ code {
|
||||||
gap: 0.65rem;
|
gap: 0.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-header-source-tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.42rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header-source-tab {
|
||||||
|
min-width: 4.7rem;
|
||||||
|
min-height: var(--admin-control-ring);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
|
border-radius: var(--launcher-radius-circle);
|
||||||
|
background: rgba(255, 255, 255, 0.045);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0 0.95rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 860;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header-source-tab:hover,
|
||||||
|
.admin-header-source-tab:focus-visible {
|
||||||
|
border-color: rgba(255, 255, 255, 0.26);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header-source-tab--active {
|
||||||
|
border-color: transparent;
|
||||||
|
background: rgba(247, 248, 244, 0.96);
|
||||||
|
color: rgb(var(--nodedc-on-accent-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
.admin-circle-action {
|
.admin-circle-action {
|
||||||
width: var(--admin-control-ring);
|
width: var(--admin-control-ring);
|
||||||
height: var(--admin-control-ring);
|
height: var(--admin-control-ring);
|
||||||
|
|
@ -2271,9 +2326,27 @@ code {
|
||||||
|
|
||||||
.access-matrix {
|
.access-matrix {
|
||||||
--access-matrix-table-bg: rgb(20, 20, 22);
|
--access-matrix-table-bg: rgb(20, 20, 22);
|
||||||
|
--access-matrix-toolbar-height: 4.35rem;
|
||||||
|
position: relative;
|
||||||
|
padding: 0 0 1rem;
|
||||||
background: var(--access-matrix-table-bg) !important;
|
background: var(--access-matrix-table-bg) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.access-matrix .table-toolbar--access-matrix {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 16;
|
||||||
|
min-height: var(--access-matrix-toolbar-height);
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem 1rem 0.7rem;
|
||||||
|
background: var(--access-matrix-table-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-matrix > .access-empty-state {
|
||||||
|
margin: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.table-shell--users,
|
.table-shell--users,
|
||||||
.table-shell--sticky-user-column {
|
.table-shell--sticky-user-column {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
@ -3572,7 +3645,7 @@ code {
|
||||||
}
|
}
|
||||||
|
|
||||||
.matrix-scroll {
|
.matrix-scroll {
|
||||||
overflow: auto;
|
overflow: visible;
|
||||||
border-radius: calc(var(--launcher-radius-card) - 0.85rem);
|
border-radius: calc(var(--launcher-radius-card) - 0.85rem);
|
||||||
background: var(--access-matrix-table-bg);
|
background: var(--access-matrix-table-bg);
|
||||||
}
|
}
|
||||||
|
|
@ -3595,6 +3668,9 @@ code {
|
||||||
}
|
}
|
||||||
|
|
||||||
.access-grid-head {
|
.access-grid-head {
|
||||||
|
position: sticky;
|
||||||
|
top: var(--access-matrix-toolbar-height);
|
||||||
|
z-index: 12;
|
||||||
min-height: 2.1rem;
|
min-height: 2.1rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.66rem;
|
font-size: 0.66rem;
|
||||||
|
|
@ -3616,7 +3692,7 @@ code {
|
||||||
}
|
}
|
||||||
|
|
||||||
.access-grid-head.access-grid-sticky {
|
.access-grid-head.access-grid-sticky {
|
||||||
z-index: 6;
|
z-index: 18;
|
||||||
}
|
}
|
||||||
|
|
||||||
.access-user-cell {
|
.access-user-cell {
|
||||||
|
|
@ -4296,6 +4372,83 @@ code {
|
||||||
width: min(24rem, 42vw);
|
width: min(24rem, 42vw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sync-diagnostics {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-health-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: start;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-health-card__main {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.28rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-health-card__main .eyebrow,
|
||||||
|
.sync-health-card__main h3,
|
||||||
|
.sync-health-card__main p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-health-card__metrics {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-health-card__metrics span {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.16rem;
|
||||||
|
min-height: 3.2rem;
|
||||||
|
align-content: center;
|
||||||
|
border-radius: var(--launcher-radius-control);
|
||||||
|
background: rgba(255, 255, 255, 0.055);
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-health-card__metrics strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1.15rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-data-table--sync td {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-history-card {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-history-card details {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-history-card summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-history-card summary:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-history-card .admin-helper-note {
|
||||||
|
margin: 0.65rem 0 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
.access-request-decision-cluster {
|
.access-request-decision-cluster {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,14 @@ import {
|
||||||
type MeResponse,
|
type MeResponse,
|
||||||
type TaskManagerWorkspaceCreationPolicy,
|
type TaskManagerWorkspaceCreationPolicy,
|
||||||
} from "../../shared/api/mockApi";
|
} from "../../shared/api/mockApi";
|
||||||
import type { TaskManagerProjectSummary, TaskManagerWorkspaceMemberRole, TaskManagerWorkspaceSummary } from "../../shared/api/adminApi";
|
import {
|
||||||
|
fetchAdminAuthentikSyncHealth,
|
||||||
|
type AuthentikSyncHealth,
|
||||||
|
type AuthentikSyncHealthState,
|
||||||
|
type TaskManagerProjectSummary,
|
||||||
|
type TaskManagerWorkspaceMemberRole,
|
||||||
|
type TaskManagerWorkspaceSummary,
|
||||||
|
} from "../../shared/api/adminApi";
|
||||||
import { uploadStorageFile } from "../../shared/api/storageApi";
|
import { uploadStorageFile } from "../../shared/api/storageApi";
|
||||||
import { cn } from "../../shared/lib/cn";
|
import { cn } from "../../shared/lib/cn";
|
||||||
import { formatDate, formatDateTime } from "../../shared/lib/format";
|
import { formatDate, formatDateTime } from "../../shared/lib/format";
|
||||||
|
|
@ -92,6 +99,8 @@ type AdminSection =
|
||||||
| "company";
|
| "company";
|
||||||
type AdminOverlayMode = "admin" | "platform";
|
type AdminOverlayMode = "admin" | "platform";
|
||||||
type AdminMutationOutcome = { ok: true } | { ok: false; message: string };
|
type AdminMutationOutcome = { ok: true } | { ok: false; message: string };
|
||||||
|
type PublicInviteTab = "incoming" | "outgoing";
|
||||||
|
type PublicInviteSourceFilter = "all" | "ndc" | "ops" | "engine";
|
||||||
type EngineWorkflowAccessRequestDecisionPatch =
|
type EngineWorkflowAccessRequestDecisionPatch =
|
||||||
Partial<Pick<EngineWorkflowAccessRequest, "comment" | "requestedServiceRole">> & {
|
Partial<Pick<EngineWorkflowAccessRequest, "comment" | "requestedServiceRole">> & {
|
||||||
serviceRole?: EngineWorkflowAccessRequest["requestedServiceRole"];
|
serviceRole?: EngineWorkflowAccessRequest["requestedServiceRole"];
|
||||||
|
|
@ -303,6 +312,9 @@ export function AdminOverlay({
|
||||||
data.clients.some((client) => client.id === activeClientId) ? activeClientId : data.clients[0]?.id ?? activeClientId
|
data.clients.some((client) => client.id === activeClientId) ? activeClientId : data.clients[0]?.id ?? activeClientId
|
||||||
);
|
);
|
||||||
const [selectedCell, setSelectedCell] = useState<{ userId: string; serviceId: string } | null>(null);
|
const [selectedCell, setSelectedCell] = useState<{ userId: string; serviceId: string } | null>(null);
|
||||||
|
const [publicInviteTab, setPublicInviteTab] = useState<PublicInviteTab>("incoming");
|
||||||
|
const [publicInviteSourceFilter, setPublicInviteSourceFilter] = useState<PublicInviteSourceFilter>("all");
|
||||||
|
const [authentikSyncHealth, setAuthentikSyncHealth] = useState<AuthentikSyncHealth | null>(null);
|
||||||
|
|
||||||
const fallbackClientId = data.clients[0]?.id ?? activeClientId;
|
const fallbackClientId = data.clients[0]?.id ?? activeClientId;
|
||||||
const selectedCompanyClientExists = data.clients.some((client) => client.id === selectedCompanyClientId);
|
const selectedCompanyClientExists = data.clients.some((client) => client.id === selectedCompanyClientId);
|
||||||
|
|
@ -320,6 +332,24 @@ export function AdminOverlay({
|
||||||
accessMatrix.cells[0] ??
|
accessMatrix.cells[0] ??
|
||||||
null;
|
null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isRoot || activeSection !== "sync") return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
fetchAdminAuthentikSyncHealth()
|
||||||
|
.then((health) => {
|
||||||
|
if (!cancelled) setAuthentikSyncHealth(health);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setAuthentikSyncHealth(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [activeSection, data.syncStatuses, isRoot]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isRoot && !selectedClientExists && data.clients.length) {
|
if (isRoot && !selectedClientExists && data.clients.length) {
|
||||||
setSelectedClientId(data.clients[0].id);
|
setSelectedClientId(data.clients[0].id);
|
||||||
|
|
@ -494,7 +524,16 @@ export function AdminOverlay({
|
||||||
isFullscreen={isContentFullscreen}
|
isFullscreen={isContentFullscreen}
|
||||||
onToggleFullscreen={() => setIsContentFullscreen((current) => !current)}
|
onToggleFullscreen={() => setIsContentFullscreen((current) => !current)}
|
||||||
onCloseContent={() => setActiveSection(null)}
|
onCloseContent={() => setActiveSection(null)}
|
||||||
/>
|
>
|
||||||
|
{isPublicPoolContext && activeSection === "invites" && publicInviteTab === "incoming" ? (
|
||||||
|
<InviteSourceFilterTabs
|
||||||
|
value={publicInviteSourceFilter}
|
||||||
|
onChange={(sourceFilter) =>
|
||||||
|
setPublicInviteSourceFilter((current) => (current === sourceFilter ? "all" : sourceFilter))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</AdminHeader>
|
||||||
<div className="admin-panel-content__body">
|
<div className="admin-panel-content__body">
|
||||||
{activeSection === "overview" ? (
|
{activeSection === "overview" ? (
|
||||||
<OverviewSection
|
<OverviewSection
|
||||||
|
|
@ -579,6 +618,9 @@ export function AdminOverlay({
|
||||||
clientId={scopedClientId}
|
clientId={scopedClientId}
|
||||||
actorUserId={me.user.id}
|
actorUserId={me.user.id}
|
||||||
isPublicPoolContext={isPublicPoolContext}
|
isPublicPoolContext={isPublicPoolContext}
|
||||||
|
publicInviteTab={publicInviteTab}
|
||||||
|
publicInviteSourceFilter={publicInviteSourceFilter}
|
||||||
|
onPublicInviteTabChange={setPublicInviteTab}
|
||||||
onCreateInvite={onCreateInvite}
|
onCreateInvite={onCreateInvite}
|
||||||
onUpdateInvite={onUpdateInvite}
|
onUpdateInvite={onUpdateInvite}
|
||||||
onDeleteInvite={onDeleteInvite}
|
onDeleteInvite={onDeleteInvite}
|
||||||
|
|
@ -591,7 +633,15 @@ export function AdminOverlay({
|
||||||
onRejectEngineWorkflowAccessRequest={onRejectEngineWorkflowAccessRequest}
|
onRejectEngineWorkflowAccessRequest={onRejectEngineWorkflowAccessRequest}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{activeSection === "sync" ? <SyncSection data={data} clientId={scopedClientId} isRoot={isRoot} onRetrySync={onRetrySync} /> : null}
|
{activeSection === "sync" ? (
|
||||||
|
<SyncSection
|
||||||
|
data={data}
|
||||||
|
clientId={scopedClientId}
|
||||||
|
isRoot={isRoot}
|
||||||
|
authentikHealth={authentikSyncHealth}
|
||||||
|
onRetrySync={onRetrySync}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{activeSection === "audit" && isRoot ? <AuditSection data={data} /> : null}
|
{activeSection === "audit" && isRoot ? <AuditSection data={data} /> : null}
|
||||||
{activeSection === "misc" && isRoot ? <MiscSection data={data} onUpdateSettings={onUpdateSettings} /> : null}
|
{activeSection === "misc" && isRoot ? <MiscSection data={data} onUpdateSettings={onUpdateSettings} /> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -605,13 +655,16 @@ function AdminHeader({
|
||||||
isFullscreen,
|
isFullscreen,
|
||||||
onToggleFullscreen,
|
onToggleFullscreen,
|
||||||
onCloseContent,
|
onCloseContent,
|
||||||
|
children,
|
||||||
}: {
|
}: {
|
||||||
isFullscreen: boolean;
|
isFullscreen: boolean;
|
||||||
onToggleFullscreen: () => void;
|
onToggleFullscreen: () => void;
|
||||||
onCloseContent: () => void;
|
onCloseContent: () => void;
|
||||||
|
children?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="admin-header">
|
<div className="admin-header">
|
||||||
|
<div className="admin-header__filters">{children}</div>
|
||||||
<div className="admin-header__actions">
|
<div className="admin-header__actions">
|
||||||
<IconButton
|
<IconButton
|
||||||
label={isFullscreen ? "Свернуть панель" : "Открыть панель на весь экран"}
|
label={isFullscreen ? "Свернуть панель" : "Открыть панель на весь экран"}
|
||||||
|
|
@ -630,6 +683,36 @@ function AdminHeader({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InviteSourceFilterTabs({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: PublicInviteSourceFilter;
|
||||||
|
onChange: (sourceFilter: Exclude<PublicInviteSourceFilter, "all">) => void;
|
||||||
|
}) {
|
||||||
|
const options: Array<{ id: Exclude<PublicInviteSourceFilter, "all">; label: string }> = [
|
||||||
|
{ id: "ndc", label: "NDC" },
|
||||||
|
{ id: "ops", label: "OPS" },
|
||||||
|
{ id: "engine", label: "ENGINE" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-header-source-tabs" aria-label="Тип входящих инвайтов">
|
||||||
|
{options.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
className={cn("admin-header-source-tab", value === option.id && "admin-header-source-tab--active")}
|
||||||
|
aria-pressed={value === option.id}
|
||||||
|
onClick={() => onChange(option.id)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function OverviewSection({
|
function OverviewSection({
|
||||||
data,
|
data,
|
||||||
clientId,
|
clientId,
|
||||||
|
|
@ -1398,6 +1481,13 @@ const syncStatusOptions: Array<AdminStatusOption<SyncState>> = [
|
||||||
{ value: "disabled", label: "Отключено", tone: "muted" },
|
{ value: "disabled", label: "Отключено", tone: "muted" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const authentikSyncHealthOptions: Array<AdminStatusOption<AuthentikSyncHealthState>> = [
|
||||||
|
{ value: "ok", label: "OK", tone: "green" },
|
||||||
|
{ value: "pending", label: "Очередь", tone: "yellow" },
|
||||||
|
{ value: "error", label: "Ошибка", tone: "red" },
|
||||||
|
{ value: "not_configured", label: "Не настроен", tone: "red" },
|
||||||
|
];
|
||||||
|
|
||||||
const auditResultOptions: Array<AdminStatusOption<"success" | "warning" | "error">> = [
|
const auditResultOptions: Array<AdminStatusOption<"success" | "warning" | "error">> = [
|
||||||
{ value: "success", label: "Успех", tone: "green" },
|
{ value: "success", label: "Успех", tone: "green" },
|
||||||
{ value: "warning", label: "Внимание", tone: "yellow" },
|
{ value: "warning", label: "Внимание", tone: "yellow" },
|
||||||
|
|
@ -3046,7 +3136,7 @@ function AccessSection({
|
||||||
return (
|
return (
|
||||||
<div className={cn("access-layout", isPublicPoolContext && "access-layout--single")}>
|
<div className={cn("access-layout", isPublicPoolContext && "access-layout--single")}>
|
||||||
<GlassSurface className="access-matrix">
|
<GlassSurface className="access-matrix">
|
||||||
<div className="table-toolbar">
|
<div className="table-toolbar table-toolbar--access-matrix">
|
||||||
<h3>Матрица доступа · {matrix.client.name}</h3>
|
<h3>Матрица доступа · {matrix.client.name}</h3>
|
||||||
<span className="muted-text">Нет данных для матрицы</span>
|
<span className="muted-text">Нет данных для матрицы</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -3064,7 +3154,7 @@ function AccessSection({
|
||||||
return (
|
return (
|
||||||
<div className={cn("access-layout", isPublicPoolContext && "access-layout--single")}>
|
<div className={cn("access-layout", isPublicPoolContext && "access-layout--single")}>
|
||||||
<GlassSurface className="access-matrix">
|
<GlassSurface className="access-matrix">
|
||||||
<div className="table-toolbar">
|
<div className="table-toolbar table-toolbar--access-matrix">
|
||||||
<h3>Матрица доступа · {matrix.client.name}</h3>
|
<h3>Матрица доступа · {matrix.client.name}</h3>
|
||||||
<span className="muted-text">Клик по ячейке открывает назначение</span>
|
<span className="muted-text">Клик по ячейке открывает назначение</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -3837,6 +3927,9 @@ function InvitesSection({
|
||||||
clientId,
|
clientId,
|
||||||
actorUserId,
|
actorUserId,
|
||||||
isPublicPoolContext,
|
isPublicPoolContext,
|
||||||
|
publicInviteTab,
|
||||||
|
publicInviteSourceFilter,
|
||||||
|
onPublicInviteTabChange,
|
||||||
onCreateInvite,
|
onCreateInvite,
|
||||||
onUpdateInvite,
|
onUpdateInvite,
|
||||||
onDeleteInvite,
|
onDeleteInvite,
|
||||||
|
|
@ -3852,6 +3945,9 @@ function InvitesSection({
|
||||||
clientId: string;
|
clientId: string;
|
||||||
actorUserId: string;
|
actorUserId: string;
|
||||||
isPublicPoolContext: boolean;
|
isPublicPoolContext: boolean;
|
||||||
|
publicInviteTab: PublicInviteTab;
|
||||||
|
publicInviteSourceFilter: PublicInviteSourceFilter;
|
||||||
|
onPublicInviteTabChange: (tab: PublicInviteTab) => void;
|
||||||
onCreateInvite: (invite: Pick<Invite, "clientId" | "email" | "role">) => void;
|
onCreateInvite: (invite: Pick<Invite, "clientId" | "email" | "role">) => void;
|
||||||
onUpdateInvite: (inviteId: string, patch: Partial<Invite>) => void;
|
onUpdateInvite: (inviteId: string, patch: Partial<Invite>) => void;
|
||||||
onDeleteInvite: (inviteId: string) => void;
|
onDeleteInvite: (inviteId: string) => void;
|
||||||
|
|
@ -3879,16 +3975,16 @@ function InvitesSection({
|
||||||
const [role, setRole] = useState<ClientMembershipRole>("member");
|
const [role, setRole] = useState<ClientMembershipRole>("member");
|
||||||
const [deleteInviteId, setDeleteInviteId] = useState<string | null>(null);
|
const [deleteInviteId, setDeleteInviteId] = useState<string | null>(null);
|
||||||
const [copiedInviteId, setCopiedInviteId] = useState<string | null>(null);
|
const [copiedInviteId, setCopiedInviteId] = useState<string | null>(null);
|
||||||
const [publicInviteTab, setPublicInviteTab] = useState<"incoming" | "outgoing">("incoming");
|
|
||||||
const invites = data.invites.filter((invite) => invite.clientId === clientId);
|
const invites = data.invites.filter((invite) => invite.clientId === clientId);
|
||||||
|
const engineWorkflowInviteRequests = data.engineWorkflowAccessRequests.filter((request) => request.requestType !== "service_role");
|
||||||
const incomingRequestsTotal =
|
const incomingRequestsTotal =
|
||||||
data.accessRequests.length +
|
data.accessRequests.length +
|
||||||
data.taskerInviteRequests.filter((request) => request.status !== "cancelled").length +
|
data.taskerInviteRequests.filter((request) => request.status !== "cancelled").length +
|
||||||
data.engineWorkflowAccessRequests.filter((request) => request.status !== "cancelled").length;
|
engineWorkflowInviteRequests.filter((request) => request.status !== "cancelled").length;
|
||||||
const pendingIncomingRequests =
|
const pendingIncomingRequests =
|
||||||
data.accessRequests.filter((request) => request.status === "new").length +
|
data.accessRequests.filter((request) => request.status === "new").length +
|
||||||
data.taskerInviteRequests.filter((request) => request.status === "new").length +
|
data.taskerInviteRequests.filter((request) => request.status === "new").length +
|
||||||
data.engineWorkflowAccessRequests.filter((request) => request.status === "new").length;
|
engineWorkflowInviteRequests.filter((request) => request.status === "new").length;
|
||||||
const deletingInvite = invites.find((invite) => invite.id === deleteInviteId) ?? null;
|
const deletingInvite = invites.find((invite) => invite.id === deleteInviteId) ?? null;
|
||||||
const actor = getUser(data, actorUserId);
|
const actor = getUser(data, actorUserId);
|
||||||
const clientOptions: Array<NodeDcSelectOption<string>> = [
|
const clientOptions: Array<NodeDcSelectOption<string>> = [
|
||||||
|
|
@ -3932,14 +4028,14 @@ function InvitesSection({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn("admin-tab-button", publicInviteTab === "incoming" && "admin-tab-button--active")}
|
className={cn("admin-tab-button", publicInviteTab === "incoming" && "admin-tab-button--active")}
|
||||||
onClick={() => setPublicInviteTab("incoming")}
|
onClick={() => onPublicInviteTabChange("incoming")}
|
||||||
>
|
>
|
||||||
Входящие · {incomingRequestsTotal}
|
Входящие · {incomingRequestsTotal}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn("admin-tab-button", publicInviteTab === "outgoing" && "admin-tab-button--active")}
|
className={cn("admin-tab-button", publicInviteTab === "outgoing" && "admin-tab-button--active")}
|
||||||
onClick={() => setPublicInviteTab("outgoing")}
|
onClick={() => onPublicInviteTabChange("outgoing")}
|
||||||
>
|
>
|
||||||
Исходящие · {invites.length}
|
Исходящие · {invites.length}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -3956,6 +4052,7 @@ function InvitesSection({
|
||||||
<AccessRequestsPanel
|
<AccessRequestsPanel
|
||||||
data={data}
|
data={data}
|
||||||
clientOptions={clientOptions}
|
clientOptions={clientOptions}
|
||||||
|
sourceFilter={publicInviteSourceFilter}
|
||||||
copiedInviteId={copiedInviteId}
|
copiedInviteId={copiedInviteId}
|
||||||
onCopyInvite={handleCopyInvite}
|
onCopyInvite={handleCopyInvite}
|
||||||
onUpdateAccessRequest={onUpdateAccessRequest}
|
onUpdateAccessRequest={onUpdateAccessRequest}
|
||||||
|
|
@ -4147,6 +4244,7 @@ function InvitesSection({
|
||||||
function AccessRequestsPanel({
|
function AccessRequestsPanel({
|
||||||
data,
|
data,
|
||||||
clientOptions,
|
clientOptions,
|
||||||
|
sourceFilter,
|
||||||
copiedInviteId,
|
copiedInviteId,
|
||||||
onCopyInvite,
|
onCopyInvite,
|
||||||
onUpdateAccessRequest,
|
onUpdateAccessRequest,
|
||||||
|
|
@ -4159,6 +4257,7 @@ function AccessRequestsPanel({
|
||||||
}: {
|
}: {
|
||||||
data: LauncherData;
|
data: LauncherData;
|
||||||
clientOptions: Array<NodeDcSelectOption<string>>;
|
clientOptions: Array<NodeDcSelectOption<string>>;
|
||||||
|
sourceFilter: PublicInviteSourceFilter;
|
||||||
copiedInviteId: string | null;
|
copiedInviteId: string | null;
|
||||||
onCopyInvite: (invite: Invite) => Promise<void>;
|
onCopyInvite: (invite: Invite) => Promise<void>;
|
||||||
onUpdateAccessRequest: (
|
onUpdateAccessRequest: (
|
||||||
|
|
@ -4187,12 +4286,16 @@ function AccessRequestsPanel({
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||||
const engineWorkflowAccessRequests = data.engineWorkflowAccessRequests
|
const engineWorkflowAccessRequests = data.engineWorkflowAccessRequests
|
||||||
.filter((request) => request.status !== "cancelled")
|
.filter((request) => request.requestType !== "service_role" && request.status !== "cancelled")
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||||
|
const showNodeDcRequests = sourceFilter === "all" || sourceFilter === "ndc";
|
||||||
|
const showTaskerRequests = sourceFilter === "all" || sourceFilter === "ops";
|
||||||
|
const showEngineRequests = sourceFilter === "all" || sourceFilter === "engine";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{showNodeDcRequests ? (
|
||||||
<GlassSurface className="table-shell">
|
<GlassSurface className="table-shell">
|
||||||
<div className="table-toolbar">
|
<div className="table-toolbar">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -4333,16 +4436,21 @@ function AccessRequestsPanel({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</GlassSurface>
|
</GlassSurface>
|
||||||
|
) : null}
|
||||||
|
{showTaskerRequests ? (
|
||||||
<TaskerInviteRequestsPanel
|
<TaskerInviteRequestsPanel
|
||||||
requests={taskerInviteRequests}
|
requests={taskerInviteRequests}
|
||||||
onApproveTaskerInviteRequest={onApproveTaskerInviteRequest}
|
onApproveTaskerInviteRequest={onApproveTaskerInviteRequest}
|
||||||
onRejectTaskerInviteRequest={onRejectTaskerInviteRequest}
|
onRejectTaskerInviteRequest={onRejectTaskerInviteRequest}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
|
{showEngineRequests ? (
|
||||||
<EngineWorkflowAccessRequestsPanel
|
<EngineWorkflowAccessRequestsPanel
|
||||||
requests={engineWorkflowAccessRequests}
|
requests={engineWorkflowAccessRequests}
|
||||||
onApproveEngineWorkflowAccessRequest={onApproveEngineWorkflowAccessRequest}
|
onApproveEngineWorkflowAccessRequest={onApproveEngineWorkflowAccessRequest}
|
||||||
onRejectEngineWorkflowAccessRequest={onRejectEngineWorkflowAccessRequest}
|
onRejectEngineWorkflowAccessRequest={onRejectEngineWorkflowAccessRequest}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -4506,7 +4614,7 @@ function EngineWorkflowAccessRequestsPanel({
|
||||||
<div>
|
<div>
|
||||||
<h3>NODE.DC Engine: запросы доступа</h3>
|
<h3>NODE.DC Engine: запросы доступа</h3>
|
||||||
<p className="admin-helper-note">
|
<p className="admin-helper-note">
|
||||||
Здесь workflow-шаринг и запросы повышения глобальной роли Engine из режима только чтения.
|
Здесь только workflow-шаринг Engine. Повышения роли Engine обрабатываются через уведомления и аудит.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -4530,14 +4638,12 @@ function EngineWorkflowAccessRequestsPanel({
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{requests.map((request) => {
|
{requests.map((request) => (
|
||||||
const isServiceRoleRequest = request.requestType === "service_role";
|
|
||||||
return (
|
|
||||||
<tr key={request.id}>
|
<tr key={request.id}>
|
||||||
<td>
|
<td>
|
||||||
<div className="access-request-applicant">
|
<div className="access-request-applicant">
|
||||||
<strong>{isServiceRoleRequest ? "Повышение роли Engine" : request.workflowName}</strong>
|
<strong>{request.workflowName}</strong>
|
||||||
<small>{isServiceRoleRequest ? `Текущая роль: ${engineServiceRoleLabel(request.currentServiceRole)}` : request.workflowId}</small>
|
<small>{request.workflowId}</small>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
|
@ -4552,23 +4658,20 @@ function EngineWorkflowAccessRequestsPanel({
|
||||||
<small>{request.requesterEmail}</small>
|
<small>{request.requesterEmail}</small>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{isServiceRoleRequest ? engineServiceRoleLabel(request.requestedServiceRole) : engineWorkflowRoleLabel(request.role)}</td>
|
<td>{engineWorkflowRoleLabel(request.role)}</td>
|
||||||
<td>
|
<td>
|
||||||
<AdminStatusPill value={request.status} options={engineWorkflowAccessRequestStatusOptions} />
|
<AdminStatusPill value={request.status} options={engineWorkflowAccessRequestStatusOptions} />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{request.status === "new" ? (
|
{request.status === "new" ? (
|
||||||
<div className="access-request-decision-cluster">
|
<div className="access-request-decision-cluster">
|
||||||
<button
|
<button
|
||||||
aria-label={`Подтвердить доступ Engine ${request.targetEmail}`}
|
aria-label={`Подтвердить доступ Engine ${request.targetEmail}`}
|
||||||
className="access-request-decision-button access-request-decision-button--accept"
|
className="access-request-decision-button access-request-decision-button--accept"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onApproveEngineWorkflowAccessRequest(request.id, isServiceRoleRequest
|
onClick={() => onApproveEngineWorkflowAccessRequest(request.id, {})}
|
||||||
? { serviceRole: request.requestedServiceRole ?? "member" }
|
>
|
||||||
: {}
|
<Check size={16} strokeWidth={2.6} />
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Check size={16} strokeWidth={2.6} />
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-label={`Отклонить доступ Engine ${request.targetEmail}`}
|
aria-label={`Отклонить доступ Engine ${request.targetEmail}`}
|
||||||
|
|
@ -4584,8 +4687,7 @@ function EngineWorkflowAccessRequestsPanel({
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -4778,64 +4880,154 @@ function SyncSection({
|
||||||
data,
|
data,
|
||||||
clientId,
|
clientId,
|
||||||
isRoot,
|
isRoot,
|
||||||
|
authentikHealth,
|
||||||
onRetrySync,
|
onRetrySync,
|
||||||
}: {
|
}: {
|
||||||
data: LauncherData;
|
data: LauncherData;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
isRoot: boolean;
|
isRoot: boolean;
|
||||||
|
authentikHealth: AuthentikSyncHealth | null;
|
||||||
onRetrySync: (syncId: string) => void;
|
onRetrySync: (syncId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const syncRows = data.syncStatuses.filter((sync) => isRoot || sync.objectId === clientId || sync.objectName.includes(getClient(data, clientId).name));
|
const syncRows = data.syncStatuses.filter((sync) => isRoot || sync.objectId === clientId || sync.objectName.includes(getClient(data, clientId).name));
|
||||||
|
const authentikRows = syncRows.filter((sync) => sync.target === "authentik");
|
||||||
|
const activeRows = authentikRows.filter((sync) => sync.state === "error" || isFreshPendingSync(sync));
|
||||||
|
const historicalPendingRows = authentikRows.filter((sync) => sync.state === "pending" && !isFreshPendingSync(sync));
|
||||||
|
const healthState = authentikHealth?.state ?? deriveAuthentikHealthState(authentikRows);
|
||||||
|
const counts = authentikHealth?.counts ?? buildLocalAuthentikSyncCounts(authentikRows);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GlassSurface className="table-shell">
|
<div className="sync-diagnostics">
|
||||||
<div className="table-toolbar">
|
<GlassSurface className="sync-health-card">
|
||||||
<div>
|
<div className="sync-health-card__main">
|
||||||
<h3>Синхронизация</h3>
|
<p className="eyebrow">Authentik</p>
|
||||||
<p className="admin-helper-note">Контроль доставки изменений в Authentik, сервисы и внешние контуры. Ошибочные строки можно отправить повторно.</p>
|
<h3>Диагностика синхронизации</h3>
|
||||||
|
<p className="admin-helper-note">
|
||||||
|
Экран показывает только реальные ошибки и свежие операции. Старые pending-строки свернуты отдельно как исторический след.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<AdminStatusPill value={healthState} options={authentikSyncHealthOptions} />
|
||||||
<table className="admin-data-table">
|
<div className="sync-health-card__metrics" aria-label="Сводка Authentik sync">
|
||||||
<thead>
|
<span>
|
||||||
<tr>
|
<strong>{counts.errors}</strong>
|
||||||
<th>Объект</th>
|
Ошибки
|
||||||
<th>Тип</th>
|
</span>
|
||||||
<th>Цель</th>
|
<span>
|
||||||
<th>Статус</th>
|
<strong>{counts.freshPending}</strong>
|
||||||
<th>Последняя sync</th>
|
Свежая очередь
|
||||||
<th>Ошибка</th>
|
</span>
|
||||||
<th>Действие</th>
|
<span>
|
||||||
</tr>
|
<strong>{counts.stalePending}</strong>
|
||||||
</thead>
|
Старый pending
|
||||||
<tbody>
|
</span>
|
||||||
{syncRows.map((sync) => (
|
<span>
|
||||||
<tr key={sync.id}>
|
<strong>{counts.synced}</strong>
|
||||||
<td>{sync.objectName}</td>
|
Synced
|
||||||
<td>{sync.objectType}</td>
|
</span>
|
||||||
<td>{targetLabel(sync.target)}</td>
|
</div>
|
||||||
<td>
|
</GlassSurface>
|
||||||
<AdminStatusPill value={sync.state} options={syncStatusOptions} />
|
|
||||||
</td>
|
<GlassSurface className="table-shell">
|
||||||
<td>{formatDateTime(sync.lastSyncAt ?? sync.updatedAt)}</td>
|
<div className="table-toolbar">
|
||||||
<td>{sync.error ?? "—"}</td>
|
<div>
|
||||||
<td>
|
<h3>Требует внимания</h3>
|
||||||
<IconButton
|
<p className="admin-helper-note">Ошибки и pending за последние 5 минут. Retry запускает реальный Authentik provision для затронутых пользователей.</p>
|
||||||
label={`Повторить синхронизацию ${sync.objectName}`}
|
</div>
|
||||||
className="admin-icon-action services-admin-table__edit"
|
</div>
|
||||||
type="button"
|
{activeRows.length ? (
|
||||||
onClick={() => onRetrySync(sync.id)}
|
<SyncRowsTable rows={activeRows} onRetrySync={onRetrySync} />
|
||||||
>
|
) : (
|
||||||
<RefreshCw size={12} />
|
<div className="access-empty-state">
|
||||||
</IconButton>
|
<strong>Активных проблем нет</strong>
|
||||||
</td>
|
<span>Если доступы работают, старые pending-строки ниже можно считать историческим шумом до отдельной чистки ledger.</span>
|
||||||
</tr>
|
</div>
|
||||||
))}
|
)}
|
||||||
</tbody>
|
</GlassSurface>
|
||||||
</table>
|
|
||||||
</GlassSurface>
|
{historicalPendingRows.length ? (
|
||||||
|
<GlassSurface className="table-shell sync-history-card">
|
||||||
|
<details>
|
||||||
|
<summary>Исторические pending · {historicalPendingRows.length}</summary>
|
||||||
|
<p className="admin-helper-note">
|
||||||
|
Эти строки давно висят в очереди и не являются надежным признаком падения Authentik. Их можно перепроверить точечно через retry.
|
||||||
|
</p>
|
||||||
|
<SyncRowsTable rows={historicalPendingRows} onRetrySync={onRetrySync} />
|
||||||
|
</details>
|
||||||
|
</GlassSurface>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SyncRowsTable({ rows, onRetrySync }: { rows: SyncStatus[]; onRetrySync: (syncId: string) => void }) {
|
||||||
|
return (
|
||||||
|
<table className="admin-data-table admin-data-table--sync">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Объект</th>
|
||||||
|
<th>Тип</th>
|
||||||
|
<th>Цель</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Последняя sync</th>
|
||||||
|
<th>Ошибка</th>
|
||||||
|
<th>Действие</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((sync) => (
|
||||||
|
<tr key={sync.id}>
|
||||||
|
<td>{sync.objectName}</td>
|
||||||
|
<td>{sync.objectType}</td>
|
||||||
|
<td>{targetLabel(sync.target)}</td>
|
||||||
|
<td>
|
||||||
|
<AdminStatusPill value={sync.state} options={syncStatusOptions} />
|
||||||
|
</td>
|
||||||
|
<td>{formatDateTime(sync.lastSyncAt ?? sync.updatedAt)}</td>
|
||||||
|
<td>{sync.error ?? "—"}</td>
|
||||||
|
<td>
|
||||||
|
<IconButton
|
||||||
|
label={`Повторить синхронизацию ${sync.objectName}`}
|
||||||
|
className="admin-icon-action services-admin-table__edit"
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRetrySync(sync.id)}
|
||||||
|
>
|
||||||
|
<RefreshCw size={12} />
|
||||||
|
</IconButton>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveAuthentikHealthState(rows: SyncStatus[]): AuthentikSyncHealthState {
|
||||||
|
if (rows.some((sync) => sync.state === "error")) return "error";
|
||||||
|
if (rows.some(isFreshPendingSync)) return "pending";
|
||||||
|
return "ok";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLocalAuthentikSyncCounts(rows: SyncStatus[]): AuthentikSyncHealth["counts"] {
|
||||||
|
const pendingRows = rows.filter((sync) => sync.state === "pending");
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: rows.length,
|
||||||
|
synced: rows.filter((sync) => sync.state === "synced").length,
|
||||||
|
pending: pendingRows.length,
|
||||||
|
freshPending: pendingRows.filter(isFreshPendingSync).length,
|
||||||
|
stalePending: pendingRows.filter((sync) => !isFreshPendingSync(sync)).length,
|
||||||
|
errors: rows.filter((sync) => sync.state === "error").length,
|
||||||
|
disabled: rows.filter((sync) => sync.state === "disabled").length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFreshPendingSync(sync: SyncStatus) {
|
||||||
|
if (sync.state !== "pending") return false;
|
||||||
|
const timestamp = Date.parse(sync.updatedAt ?? sync.lastSyncAt ?? "");
|
||||||
|
if (!Number.isFinite(timestamp)) return false;
|
||||||
|
return Date.now() - timestamp <= 5 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
function AuditSection({ data }: { data: LauncherData }) {
|
function AuditSection({ data }: { data: LauncherData }) {
|
||||||
return (
|
return (
|
||||||
<GlassSurface className="table-shell">
|
<GlassSurface className="table-shell">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue