mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-18 16:03:00 +00:00
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
200 lines
9.3 KiB
TypeScript
200 lines
9.3 KiB
TypeScript
// Overview shows only problems; Setup and health also shows informational context.
|
|
|
|
import { Link } from "react-router-dom";
|
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { toast } from "sonner";
|
|
import { AlertTriangle, ArrowRight, ExternalLink, Info, XCircle, type LucideIcon } from "lucide-react";
|
|
import { StatusBadge } from "@/components/ui/kit";
|
|
import { Button } from "@/components/ui/button";
|
|
import { useConfirm } from "@/components/ConfirmDialog";
|
|
import { docsUrl } from "@/lib/docs";
|
|
import { useAdminPerm } from "@/hooks/useAdminPerm";
|
|
import { INSTANCE_HEALTH_KEY, useInstanceHealth } from "@/hooks/useInstanceHealth";
|
|
import { AdminPerm } from "@/lib/auth/permissions";
|
|
import { TONE_PANEL, TONE_TEXT, type Tone } from "@/lib/tones";
|
|
import { cn } from "@/lib/utils";
|
|
import type { CheckSeverity, InstanceCheck } from "@/lib/api/client/admin/instance";
|
|
import { deleteExpiredInvitations } from "@/lib/api/client/admin/instance";
|
|
|
|
const SEVERITY_ORDER: CheckSeverity[] = ["error", "warning", "info"];
|
|
|
|
interface SeverityStyle {
|
|
label: string;
|
|
heading: string;
|
|
icon: LucideIcon;
|
|
tone: Tone;
|
|
}
|
|
|
|
const SEVERITY_STYLES: Record<CheckSeverity, SeverityStyle> = {
|
|
error: { label: "Error", heading: "Errors", icon: XCircle, tone: "danger" },
|
|
warning: { label: "Warning", heading: "Warnings", icon: AlertTriangle, tone: "warning" },
|
|
info: { label: "Info", heading: "Worth knowing", icon: Info, tone: "info" },
|
|
};
|
|
|
|
// A severity the frontend does not know yet still renders, as info.
|
|
function toneFor(severity: CheckSeverity): SeverityStyle {
|
|
return SEVERITY_STYLES[severity] ?? SEVERITY_STYLES.info;
|
|
}
|
|
|
|
// The whole list, errors first. Used by the Setup and health page.
|
|
export function InstanceFindings({ checks }: { checks: InstanceCheck[] }) {
|
|
return (
|
|
<div className="space-y-6">
|
|
{SEVERITY_ORDER.map((severity) => {
|
|
const group = checks.filter((c) => c.severity === severity);
|
|
if (group.length === 0) return null;
|
|
const tone = toneFor(severity);
|
|
return (
|
|
<section key={severity}>
|
|
<div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
|
<tone.icon className={cn("size-3.5", TONE_TEXT[tone.tone])} />
|
|
{tone.heading}
|
|
<span className="tabular-nums text-subtle-foreground">{group.length}</span>
|
|
</div>
|
|
<ul className="divide-y divide-border overflow-hidden surface-lit rounded-xl border border-border bg-card">
|
|
{group.map((check) => (
|
|
<FindingRow key={`${check.id}:${check.target ?? ""}`} check={check} />
|
|
))}
|
|
</ul>
|
|
</section>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FindingRow({ check }: { check: InstanceCheck }) {
|
|
const tone = toneFor(check.severity);
|
|
const canGrantAdmin = useAdminPerm(AdminPerm.GrantAdminAccess);
|
|
const canManageOrganizations = useAdminPerm(AdminPerm.ManageOrganizations);
|
|
return (
|
|
<li className="flex items-start gap-3 px-4 py-3">
|
|
<tone.icon className={cn("mt-0.5 size-4 shrink-0", TONE_TEXT[tone.tone])} />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="text-[13px] font-medium text-foreground">{check.title}</span>
|
|
{check.target && (
|
|
<span className="max-w-[18rem] truncate rounded-[4px] border border-border bg-muted/60 px-1.5 py-px font-mono text-[11px] text-muted-foreground">
|
|
{check.target}
|
|
</span>
|
|
)}
|
|
<StatusBadge tone={tone.tone}>{tone.label}</StatusBadge>
|
|
</div>
|
|
<p className="mt-1 break-words text-[13px] leading-relaxed text-muted-foreground">{check.message}</p>
|
|
<div className="mt-2 flex flex-wrap items-center gap-3">
|
|
{check.id === "single_platform_admin" && canGrantAdmin && (
|
|
<Link to="/admins" className="inline-flex items-center gap-1 text-xs font-medium text-[var(--admin-accent-strong)] hover:underline">
|
|
Manage admins
|
|
<ArrowRight className="size-3" />
|
|
</Link>
|
|
)}
|
|
{check.id === "expired_invitations" && canManageOrganizations && (
|
|
<ExpiredInvitationsCleanup />
|
|
)}
|
|
{check.docs && (
|
|
<a
|
|
href={docsUrl(check.docs)}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="inline-flex items-center gap-1 text-xs font-medium text-[var(--admin-accent-strong)] hover:underline"
|
|
>
|
|
Learn more
|
|
<ExternalLink className="size-3" />
|
|
</a>
|
|
)}
|
|
<code className="font-mono text-[11px] text-subtle-foreground">{check.id}</code>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
);
|
|
}
|
|
|
|
function ExpiredInvitationsCleanup() {
|
|
const confirm = useConfirm();
|
|
const qc = useQueryClient();
|
|
const cleanup = useMutation({
|
|
mutationFn: deleteExpiredInvitations,
|
|
onSuccess: async () => {
|
|
toast.success("Expired invitations removed");
|
|
await qc.invalidateQueries({ queryKey: INSTANCE_HEALTH_KEY });
|
|
},
|
|
onError: (e: Error) => toast.error(e.message || "Could not remove expired invitations"),
|
|
});
|
|
|
|
async function onCleanup() {
|
|
const ok = await confirm({
|
|
title: "Remove expired invitations?",
|
|
description: "This permanently removes expired invitation records across all workspaces. Active invitations, accounts and workspace members are unchanged.",
|
|
confirmLabel: "Remove expired invitations",
|
|
destructive: true,
|
|
});
|
|
if (ok) cleanup.mutate();
|
|
}
|
|
|
|
return (
|
|
<Button size="sm" variant="outline" onClick={onCleanup} disabled={cleanup.isPending}>
|
|
{cleanup.isPending ? "Removing..." : "Remove expired invitations"}
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
const OVERVIEW_LIMIT = 4;
|
|
|
|
// Overview's problems strip. Silent when the instance is clean or when the
|
|
// caller cannot read the endpoint, so it never becomes noise on the page.
|
|
export function InstanceProblemsPanel() {
|
|
const canRead = useAdminPerm(AdminPerm.ViewAnalytics);
|
|
const healthQ = useInstanceHealth({ enabled: canRead });
|
|
const checks = healthQ.data?.checks ?? [];
|
|
const actionable = checks.filter((c) => c.severity === "error" || c.severity === "warning");
|
|
|
|
if (healthQ.isError || actionable.length === 0) return null;
|
|
|
|
const errors = actionable.filter((c) => c.severity === "error").length;
|
|
const shown = actionable.slice(0, OVERVIEW_LIMIT);
|
|
const panelTone: Tone = errors > 0 ? "danger" : "warning";
|
|
const HeadIcon = errors > 0 ? XCircle : AlertTriangle;
|
|
|
|
return (
|
|
<div className={cn("mb-8 overflow-hidden rounded-lg border", TONE_PANEL[panelTone])}>
|
|
<div className="flex items-center justify-between gap-3 px-4 pt-3 pb-2">
|
|
<div className="flex min-w-0 items-center gap-2 text-[13px] font-medium text-foreground">
|
|
<HeadIcon className={cn("size-4 shrink-0", TONE_TEXT[panelTone])} />
|
|
{actionable.length === 1
|
|
? "1 problem needs attention"
|
|
: `${actionable.length} problems need attention`}
|
|
</div>
|
|
<Link
|
|
to="/health"
|
|
className="inline-flex shrink-0 items-center gap-1 rounded px-1 py-0.5 text-xs font-medium text-[var(--admin-accent-strong)] hover:underline"
|
|
>
|
|
Setup and health
|
|
<ArrowRight className="size-3" />
|
|
</Link>
|
|
</div>
|
|
<ul className="px-4 pb-3">
|
|
{shown.map((check) => {
|
|
const tone = toneFor(check.severity);
|
|
return (
|
|
<li
|
|
key={`${check.id}:${check.target ?? ""}`}
|
|
className="flex items-start gap-2 py-0.5 text-[13px] leading-relaxed"
|
|
>
|
|
<tone.icon className={cn("mt-[3px] size-3.5 shrink-0", TONE_TEXT[tone.tone])} />
|
|
<span className="min-w-0">
|
|
<span className="text-foreground">{check.title}</span>
|
|
{check.target && <span className="text-muted-foreground"> ({check.target})</span>}
|
|
</span>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
{actionable.length > shown.length && (
|
|
<div className="border-t border-border/60 px-4 py-2 text-xs text-muted-foreground">
|
|
{actionable.length - shown.length} more on Setup and health.
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|