mirror of
https://github.com/freedbygrace/ActiveDirectoryManager.git
synced 2026-08-21 07:37:32 +00:00
Enhance dashboard to display data for all Active Directory objects and dynamically generate available fields.
Replit-Commit-Author: Agent Replit-Commit-Session-Id: 705f2157-ef97-4fbd-89e4-8c7f2ecaea90 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/7ed01c5f-a82d-405a-b728-b2e3d127c60c/3caa578d-3a2c-45ec-a6c0-75313ad18bfb.jpg
This commit is contained in:
@@ -0,0 +1,421 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
import { ColorPicker } from "@/components/ui/color-picker";
|
||||||
|
import { CopyIcon, Share2, CheckIcon, Download } from "lucide-react";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { DashboardConfig } from "./dashboard-layout";
|
||||||
|
import { generateSnapshot } from "@/lib/snapshot-utils";
|
||||||
|
|
||||||
|
interface ShareDashboardDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
dashboard: DashboardConfig;
|
||||||
|
dataSources: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
data: any[];
|
||||||
|
fields: Array<{ name: string; type: string }>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShareDashboardDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
dashboard,
|
||||||
|
dataSources
|
||||||
|
}: ShareDashboardDialogProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [shareUrl, setShareUrl] = useState("");
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [logoUrl, setLogoUrl] = useState("");
|
||||||
|
const [companyName, setCompanyName] = useState("");
|
||||||
|
const [customTitle, setCustomTitle] = useState(dashboard.name);
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [primaryColor, setPrimaryColor] = useState("#0f172a");
|
||||||
|
const [secondaryColor, setSecondaryColor] = useState("#6366f1");
|
||||||
|
const [showDate, setShowDate] = useState(true);
|
||||||
|
const [showFooter, setShowFooter] = useState(true);
|
||||||
|
const [footerText, setFooterText] = useState("Generated with Active Directory Management Platform");
|
||||||
|
const [expiryDays, setExpiryDays] = useState(7);
|
||||||
|
const [includeData, setIncludeData] = useState(true);
|
||||||
|
const [isSharing, setIsSharing] = useState(false);
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
|
||||||
|
// When user uploads a logo
|
||||||
|
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
setLogoUrl(reader.result as string);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate a sharable URL with snapshot data
|
||||||
|
const handleGenerateShareLink = async () => {
|
||||||
|
setIsSharing(true);
|
||||||
|
try {
|
||||||
|
const snapshotData = generateSnapshot({
|
||||||
|
dashboard,
|
||||||
|
dataSources: includeData ? dataSources : [],
|
||||||
|
branding: {
|
||||||
|
logoUrl,
|
||||||
|
companyName,
|
||||||
|
title: customTitle,
|
||||||
|
description,
|
||||||
|
primaryColor,
|
||||||
|
secondaryColor,
|
||||||
|
showDate,
|
||||||
|
showFooter,
|
||||||
|
footerText,
|
||||||
|
expiresInDays: expiryDays
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// In a real app, this would make an API call to store the snapshot
|
||||||
|
// For now, we're simulating it with localStorage
|
||||||
|
const snapshotId = `dash_${Date.now().toString(36)}`;
|
||||||
|
localStorage.setItem(`snapshot_${snapshotId}`, JSON.stringify(snapshotData));
|
||||||
|
|
||||||
|
// Generate share URL
|
||||||
|
const shareUrl = `${window.location.origin}/dashboards/shared/${snapshotId}`;
|
||||||
|
setShareUrl(shareUrl);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Share link generated",
|
||||||
|
description: "The share link for this dashboard has been created.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error generating share link:", error);
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to generate share link. Please try again.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSharing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Copy the URL to clipboard
|
||||||
|
const handleCopyLink = () => {
|
||||||
|
navigator.clipboard.writeText(shareUrl);
|
||||||
|
setCopied(true);
|
||||||
|
toast({
|
||||||
|
title: "Copied!",
|
||||||
|
description: "Share link copied to clipboard.",
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setCopied(false);
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate a PDF or image snapshot for download
|
||||||
|
const handleDownloadSnapshot = async () => {
|
||||||
|
setIsGenerating(true);
|
||||||
|
try {
|
||||||
|
const snapshotData = generateSnapshot({
|
||||||
|
dashboard,
|
||||||
|
dataSources: includeData ? dataSources : [],
|
||||||
|
branding: {
|
||||||
|
logoUrl,
|
||||||
|
companyName,
|
||||||
|
title: customTitle,
|
||||||
|
description,
|
||||||
|
primaryColor,
|
||||||
|
secondaryColor,
|
||||||
|
showDate,
|
||||||
|
showFooter,
|
||||||
|
footerText,
|
||||||
|
expiresInDays: 0 // Download doesn't expire
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// In a real implementation, we would generate a PDF or image here
|
||||||
|
// For now, we'll just download the JSON data
|
||||||
|
const dataStr = JSON.stringify(snapshotData, null, 2);
|
||||||
|
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(dataBlob);
|
||||||
|
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${dashboard.name.replace(/\s+/g, '_')}_snapshot.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Snapshot downloaded",
|
||||||
|
description: "Your dashboard snapshot has been downloaded.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error generating snapshot:", error);
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to generate snapshot. Please try again.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Share Dashboard: {dashboard.name}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a shareable snapshot of this dashboard with custom branding.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Tabs defaultValue="link" className="w-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="link">Share Link</TabsTrigger>
|
||||||
|
<TabsTrigger value="branding">Branding Options</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="link" className="space-y-4 mt-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Include data in snapshot</Label>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
checked={includeData}
|
||||||
|
onCheckedChange={setIncludeData}
|
||||||
|
id="include-data"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="include-data">
|
||||||
|
{includeData
|
||||||
|
? "Data included (snapshot works offline)"
|
||||||
|
: "Live data only (requires access to API)"}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Snapshot expiry</Label>
|
||||||
|
<div className="flex flex-col space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>Expires after</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{expiryDays === 0 ? "Never expires" : `${expiryDays} days`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[expiryDays]}
|
||||||
|
min={0}
|
||||||
|
max={30}
|
||||||
|
step={1}
|
||||||
|
onValueChange={(value) => setExpiryDays(value[0])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!shareUrl ? (
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleGenerateShareLink}
|
||||||
|
disabled={isSharing}
|
||||||
|
>
|
||||||
|
{isSharing ? "Generating..." : "Generate Share Link"}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex mt-4 space-x-2">
|
||||||
|
<Input
|
||||||
|
value={shareUrl}
|
||||||
|
readOnly
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleCopyLink}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
>
|
||||||
|
{copied ? <CheckIcon className="h-4 w-4" /> : <CopyIcon className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2 mt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={handleDownloadSnapshot}
|
||||||
|
disabled={isGenerating}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
{isGenerating ? "Generating..." : "Download Snapshot"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={handleGenerateShareLink}
|
||||||
|
disabled={isSharing}
|
||||||
|
>
|
||||||
|
<Share2 className="h-4 w-4 mr-2" />
|
||||||
|
{isSharing ? "Regenerating..." : "Regenerate Link"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="branding" className="space-y-4 mt-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="company-logo">Company Logo</Label>
|
||||||
|
<Input
|
||||||
|
id="company-logo"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleLogoUpload}
|
||||||
|
/>
|
||||||
|
{logoUrl && (
|
||||||
|
<div className="mt-2 border rounded-md p-2 flex justify-center">
|
||||||
|
<img
|
||||||
|
src={logoUrl}
|
||||||
|
alt="Company Logo"
|
||||||
|
className="max-h-20 object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="company-name">Company Name</Label>
|
||||||
|
<Input
|
||||||
|
id="company-name"
|
||||||
|
value={companyName}
|
||||||
|
onChange={(e) => setCompanyName(e.target.value)}
|
||||||
|
placeholder="Your Company Name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="custom-title">Dashboard Title</Label>
|
||||||
|
<Input
|
||||||
|
id="custom-title"
|
||||||
|
value={customTitle}
|
||||||
|
onChange={(e) => setCustomTitle(e.target.value)}
|
||||||
|
placeholder="Dashboard Title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Dashboard Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Add a description for this dashboard"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 mt-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Primary Color</Label>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div
|
||||||
|
className="w-8 h-8 rounded-md border"
|
||||||
|
style={{ backgroundColor: primaryColor }}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={primaryColor}
|
||||||
|
onChange={(e) => setPrimaryColor(e.target.value)}
|
||||||
|
placeholder="#000000"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Secondary Color</Label>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div
|
||||||
|
className="w-8 h-8 rounded-md border"
|
||||||
|
style={{ backgroundColor: secondaryColor }}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={secondaryColor}
|
||||||
|
onChange={(e) => setSecondaryColor(e.target.value)}
|
||||||
|
placeholder="#000000"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 mt-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
checked={showDate}
|
||||||
|
onCheckedChange={setShowDate}
|
||||||
|
id="show-date"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="show-date">Show date in snapshot</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
checked={showFooter}
|
||||||
|
onCheckedChange={setShowFooter}
|
||||||
|
id="show-footer"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="show-footer">Show footer in snapshot</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showFooter && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="footer-text">Footer Text</Label>
|
||||||
|
<Input
|
||||||
|
id="footer-text"
|
||||||
|
value={footerText}
|
||||||
|
onChange={(e) => setFooterText(e.target.value)}
|
||||||
|
placeholder="Footer text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<DialogFooter className="flex flex-col-reverse sm:flex-row sm:justify-between sm:space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Check, Droplet } from "lucide-react";
|
||||||
|
|
||||||
|
interface ColorPickerProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRESET_COLORS = [
|
||||||
|
"#000000", "#ffffff", "#f44336", "#e91e63", "#9c27b0", "#673ab7",
|
||||||
|
"#3f51b5", "#2196f3", "#03a9f4", "#00bcd4", "#009688", "#4caf50",
|
||||||
|
"#8bc34a", "#cddc39", "#ffeb3b", "#ffc107", "#ff9800", "#ff5722",
|
||||||
|
"#795548", "#607d8b", "#9e9e9e", "#f5f5f5", "#1a237e", "#004d40",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ColorPicker({ value, onChange, className = "" }: ColorPickerProps) {
|
||||||
|
const [color, setColor] = useState(value || "#000000");
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setColor(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const handleChangeComplete = (newColor: string) => {
|
||||||
|
setColor(newColor);
|
||||||
|
onChange(newColor);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValidColor = (color: string) => {
|
||||||
|
const s = new Option().style;
|
||||||
|
s.color = color;
|
||||||
|
return s.color !== '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newColor = e.target.value;
|
||||||
|
setColor(newColor);
|
||||||
|
|
||||||
|
// Validate color format before updating
|
||||||
|
if (isValidColor(newColor)) {
|
||||||
|
onChange(newColor);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={`w-10 h-10 p-0 border-2 ${className}`}
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
aria-label="Pick a color"
|
||||||
|
>
|
||||||
|
<span className="sr-only">Pick a color</span>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-64">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div
|
||||||
|
className="w-full h-8 rounded cursor-pointer border"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
value={color}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
if (typeof window !== 'undefined' && 'EyeDropper' in window) {
|
||||||
|
// @ts-ignore
|
||||||
|
const eyeDropper = new window.EyeDropper();
|
||||||
|
eyeDropper.open()
|
||||||
|
.then((result: { sRGBHex: string }) => {
|
||||||
|
handleChangeComplete(result.sRGBHex);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// User canceled the eyedropper
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Droplet className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-8 gap-1">
|
||||||
|
{PRESET_COLORS.map((presetColor) => (
|
||||||
|
<Button
|
||||||
|
key={presetColor}
|
||||||
|
style={{ backgroundColor: presetColor }}
|
||||||
|
className="w-6 h-6 p-0 rounded-sm"
|
||||||
|
onClick={() => {
|
||||||
|
handleChangeComplete(presetColor);
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{color.toLowerCase() === presetColor.toLowerCase() && (
|
||||||
|
<Check className="h-3 w-3 text-white" />
|
||||||
|
)}
|
||||||
|
<span className="sr-only">Select color {presetColor}</span>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { DashboardConfig } from "@/components/dashboard/dashboard-layout";
|
||||||
|
|
||||||
|
// Interface for snapshot branding options
|
||||||
|
export interface SnapshotBranding {
|
||||||
|
logoUrl: string;
|
||||||
|
companyName: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryColor: string;
|
||||||
|
secondaryColor: string;
|
||||||
|
showDate: boolean;
|
||||||
|
showFooter: boolean;
|
||||||
|
footerText: string;
|
||||||
|
expiresInDays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interface for snapshot data
|
||||||
|
export interface SnapshotData {
|
||||||
|
dashboard: DashboardConfig;
|
||||||
|
dataSources: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
data: any[];
|
||||||
|
fields: Array<{ name: string; type: string }>;
|
||||||
|
}>;
|
||||||
|
branding: SnapshotBranding;
|
||||||
|
createdAt: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a snapshot of the dashboard data for sharing
|
||||||
|
*/
|
||||||
|
export function generateSnapshot({
|
||||||
|
dashboard,
|
||||||
|
dataSources,
|
||||||
|
branding,
|
||||||
|
}: {
|
||||||
|
dashboard: DashboardConfig;
|
||||||
|
dataSources: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
data: any[];
|
||||||
|
fields: Array<{ name: string; type: string }>;
|
||||||
|
}>;
|
||||||
|
branding: SnapshotBranding;
|
||||||
|
}): SnapshotData {
|
||||||
|
const createdAt = new Date().toISOString();
|
||||||
|
|
||||||
|
// Calculate expiry date if applicable
|
||||||
|
let expiresAt: string | null = null;
|
||||||
|
if (branding.expiresInDays > 0) {
|
||||||
|
const expiry = new Date();
|
||||||
|
expiry.setDate(expiry.getDate() + branding.expiresInDays);
|
||||||
|
expiresAt = expiry.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshotData: SnapshotData = {
|
||||||
|
dashboard,
|
||||||
|
dataSources,
|
||||||
|
branding,
|
||||||
|
createdAt,
|
||||||
|
expiresAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
return snapshotData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a snapshot has expired
|
||||||
|
*/
|
||||||
|
export function isSnapshotExpired(snapshot: SnapshotData): boolean {
|
||||||
|
if (!snapshot.expiresAt) {
|
||||||
|
return false; // No expiry date means it never expires
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiryDate = new Date(snapshot.expiresAt);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
return now > expiryDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date for display in the snapshot
|
||||||
|
*/
|
||||||
|
export function formatSnapshotDate(dateString: string): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,9 +2,10 @@ import { useState, useEffect } from "react";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { DashboardLayout, DashboardConfig } from "@/components/dashboard/dashboard-layout";
|
import { DashboardLayout, DashboardConfig } from "@/components/dashboard/dashboard-layout";
|
||||||
|
import { ShareDashboardDialog } from "@/components/dashboard/share-dashboard-dialog";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Plus, RefreshCw } from "lucide-react";
|
import { Plus, RefreshCw, Share2 } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -60,75 +61,72 @@ export default function DashboardPage() {
|
|||||||
queryKey: ['/api/ldap-connections'],
|
queryKey: ['/api/ldap-connections'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Generic function to fetch and prepare dashboard data
|
||||||
|
const fetchDashboardData = async (endpoint: string, id: string, displayName: string) => {
|
||||||
|
const response = await fetch(endpoint);
|
||||||
|
if (!response.ok) throw new Error(`Failed to fetch ${displayName}`);
|
||||||
|
const responseData = await response.json();
|
||||||
|
|
||||||
|
// Get the structure of the data to determine field types
|
||||||
|
const data = responseData.data || [];
|
||||||
|
const fields = getFieldTypes(data);
|
||||||
|
|
||||||
|
console.log(`${displayName} data fields:`, fields);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: displayName,
|
||||||
|
data,
|
||||||
|
fields,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// Get users as a data source
|
// Get users as a data source
|
||||||
const { data: usersData, isLoading: isLoadingUsers } = useQuery<any>({
|
const { data: usersData, isLoading: isLoadingUsers } = useQuery<any>({
|
||||||
queryKey: ['/api/user-dashboard-data'],
|
queryKey: ['/api/user-dashboard-data'],
|
||||||
queryFn: async () => {
|
queryFn: () => fetchDashboardData('/api/user-dashboard-data', 'users', 'Active Directory Users'),
|
||||||
const response = await fetch('/api/user-dashboard-data');
|
|
||||||
if (!response.ok) throw new Error('Failed to fetch users');
|
|
||||||
const users = await response.json();
|
|
||||||
|
|
||||||
// Get the structure of the data to determine field types
|
|
||||||
const userData = users.data || [];
|
|
||||||
const fields = getFieldTypes(userData);
|
|
||||||
|
|
||||||
console.log('User data fields:', fields);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: 'users',
|
|
||||||
name: 'Active Directory Users',
|
|
||||||
data: userData,
|
|
||||||
fields,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
enabled: true,
|
enabled: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get computers as a data source
|
// Get computers as a data source
|
||||||
const { data: computersData, isLoading: isLoadingComputers } = useQuery<any>({
|
const { data: computersData, isLoading: isLoadingComputers } = useQuery<any>({
|
||||||
queryKey: ['/api/computer-dashboard-data'],
|
queryKey: ['/api/computer-dashboard-data'],
|
||||||
queryFn: async () => {
|
queryFn: () => fetchDashboardData('/api/computer-dashboard-data', 'computers', 'Active Directory Computers'),
|
||||||
const response = await fetch('/api/computer-dashboard-data');
|
|
||||||
if (!response.ok) throw new Error('Failed to fetch computers');
|
|
||||||
const computers = await response.json();
|
|
||||||
|
|
||||||
// Get the structure of the data to determine field types
|
|
||||||
const computerData = computers.data || [];
|
|
||||||
const fields = getFieldTypes(computerData);
|
|
||||||
|
|
||||||
console.log('Computer data fields:', fields);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: 'computers',
|
|
||||||
name: 'Active Directory Computers',
|
|
||||||
data: computerData,
|
|
||||||
fields,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
enabled: true,
|
enabled: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get groups as a data source
|
// Get groups as a data source
|
||||||
const { data: groupsData, isLoading: isLoadingGroups } = useQuery<any>({
|
const { data: groupsData, isLoading: isLoadingGroups } = useQuery<any>({
|
||||||
queryKey: ['/api/group-dashboard-data'],
|
queryKey: ['/api/group-dashboard-data'],
|
||||||
queryFn: async () => {
|
queryFn: () => fetchDashboardData('/api/group-dashboard-data', 'groups', 'Active Directory Groups'),
|
||||||
const response = await fetch('/api/group-dashboard-data');
|
enabled: true,
|
||||||
if (!response.ok) throw new Error('Failed to fetch groups');
|
});
|
||||||
const groups = await response.json();
|
|
||||||
|
|
||||||
// Get the structure of the data to determine field types
|
// Get OUs as a data source
|
||||||
const groupData = groups.data || [];
|
const { data: ousData, isLoading: isLoadingOUs } = useQuery<any>({
|
||||||
const fields = getFieldTypes(groupData);
|
queryKey: ['/api/ou-dashboard-data'],
|
||||||
|
queryFn: () => fetchDashboardData('/api/ou-dashboard-data', 'ous', 'Organizational Units'),
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
console.log('Group data fields:', fields);
|
// Get domains as a data source
|
||||||
|
const { data: domainsData, isLoading: isLoadingDomains } = useQuery<any>({
|
||||||
|
queryKey: ['/api/domain-dashboard-data'],
|
||||||
|
queryFn: () => fetchDashboardData('/api/domain-dashboard-data', 'domains', 'Active Directory Domains'),
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
// Get sites as a data source
|
||||||
id: 'groups',
|
const { data: sitesData, isLoading: isLoadingSites } = useQuery<any>({
|
||||||
name: 'Active Directory Groups',
|
queryKey: ['/api/site-dashboard-data'],
|
||||||
data: groupData,
|
queryFn: () => fetchDashboardData('/api/site-dashboard-data', 'sites', 'Active Directory Sites'),
|
||||||
fields,
|
enabled: true,
|
||||||
};
|
});
|
||||||
},
|
|
||||||
|
// Get subnets as a data source
|
||||||
|
const { data: subnetsData, isLoading: isLoadingSubnets } = useQuery<any>({
|
||||||
|
queryKey: ['/api/subnet-dashboard-data'],
|
||||||
|
queryFn: () => fetchDashboardData('/api/subnet-dashboard-data', 'subnets', 'Active Directory Subnets'),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -152,14 +150,25 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
// Update data sources when API data is loaded
|
// Update data sources when API data is loaded
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoadingUsers && !isLoadingComputers && !isLoadingGroups) {
|
if (!isLoadingUsers &&
|
||||||
|
!isLoadingComputers &&
|
||||||
|
!isLoadingGroups &&
|
||||||
|
!isLoadingOUs &&
|
||||||
|
!isLoadingDomains &&
|
||||||
|
!isLoadingSites &&
|
||||||
|
!isLoadingSubnets) {
|
||||||
const sources = [];
|
const sources = [];
|
||||||
|
|
||||||
|
// Add all AD object types as data sources
|
||||||
if (usersData) sources.push(usersData);
|
if (usersData) sources.push(usersData);
|
||||||
if (computersData) sources.push(computersData);
|
if (computersData) sources.push(computersData);
|
||||||
if (groupsData) sources.push(groupsData);
|
if (groupsData) sources.push(groupsData);
|
||||||
|
if (ousData) sources.push(ousData);
|
||||||
|
if (domainsData) sources.push(domainsData);
|
||||||
|
if (sitesData) sources.push(sitesData);
|
||||||
|
if (subnetsData) sources.push(subnetsData);
|
||||||
|
|
||||||
// Add a mock audit logs data source
|
// Add audit logs data source
|
||||||
sources.push({
|
sources.push({
|
||||||
id: 'audit-logs',
|
id: 'audit-logs',
|
||||||
name: 'Audit Logs',
|
name: 'Audit Logs',
|
||||||
@@ -178,7 +187,22 @@ export default function DashboardPage() {
|
|||||||
setDataSources(sources);
|
setDataSources(sources);
|
||||||
setDataSourcesLoading(false);
|
setDataSourcesLoading(false);
|
||||||
}
|
}
|
||||||
}, [isLoadingUsers, isLoadingComputers, isLoadingGroups, usersData, computersData, groupsData]);
|
}, [
|
||||||
|
isLoadingUsers,
|
||||||
|
isLoadingComputers,
|
||||||
|
isLoadingGroups,
|
||||||
|
isLoadingOUs,
|
||||||
|
isLoadingDomains,
|
||||||
|
isLoadingSites,
|
||||||
|
isLoadingSubnets,
|
||||||
|
usersData,
|
||||||
|
computersData,
|
||||||
|
groupsData,
|
||||||
|
ousData,
|
||||||
|
domainsData,
|
||||||
|
sitesData,
|
||||||
|
subnetsData
|
||||||
|
]);
|
||||||
|
|
||||||
// Helper function to determine field types from data
|
// Helper function to determine field types from data
|
||||||
function getFieldTypes(data: any[]): Array<{ name: string; type: string }> {
|
function getFieldTypes(data: any[]): Array<{ name: string; type: string }> {
|
||||||
|
|||||||
@@ -5056,6 +5056,162 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/ou-dashboard-data:
|
||||||
|
* get:
|
||||||
|
* summary: Get Organizational Unit data for dashboard visualizations
|
||||||
|
* tags: [Dashboard]
|
||||||
|
* security:
|
||||||
|
* - cookieAuth: []
|
||||||
|
* - bearerAuth: []
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Organizational Unit data for dashboard
|
||||||
|
* 401:
|
||||||
|
* $ref: '#/components/responses/UnauthorizedError'
|
||||||
|
*/
|
||||||
|
app.get("/api/ou-dashboard-data", requireAuth({ allowApiToken: true }), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
// Get data from the first available LDAP connection
|
||||||
|
const connections = await storage.listLdapConnections();
|
||||||
|
|
||||||
|
if (connections.length === 0) {
|
||||||
|
return res.status(200).json({ data: [], message: "No LDAP connections available" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectionId = connections[0].id;
|
||||||
|
const ous = await storage.listAdOrgUnits(connectionId);
|
||||||
|
|
||||||
|
// Add objectType to make filtering easier in the dashboard
|
||||||
|
const result = ous.map(ou => ({
|
||||||
|
...ou,
|
||||||
|
objectType: 'organizationalUnit'
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ data: result });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/domain-dashboard-data:
|
||||||
|
* get:
|
||||||
|
* summary: Get domain data for dashboard visualizations
|
||||||
|
* tags: [Dashboard]
|
||||||
|
* security:
|
||||||
|
* - cookieAuth: []
|
||||||
|
* - bearerAuth: []
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Domain data for dashboard
|
||||||
|
* 401:
|
||||||
|
* $ref: '#/components/responses/UnauthorizedError'
|
||||||
|
*/
|
||||||
|
app.get("/api/domain-dashboard-data", requireAuth({ allowApiToken: true }), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
// Get data from the first available LDAP connection
|
||||||
|
const connections = await storage.listLdapConnections();
|
||||||
|
|
||||||
|
if (connections.length === 0) {
|
||||||
|
return res.status(200).json({ data: [], message: "No LDAP connections available" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectionId = connections[0].id;
|
||||||
|
const domains = await storage.listAdDomains(connectionId);
|
||||||
|
|
||||||
|
// Add objectType to make filtering easier in the dashboard
|
||||||
|
const result = domains.map(domain => ({
|
||||||
|
...domain,
|
||||||
|
objectType: 'domain'
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ data: result });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/site-dashboard-data:
|
||||||
|
* get:
|
||||||
|
* summary: Get site data for dashboard visualizations
|
||||||
|
* tags: [Dashboard]
|
||||||
|
* security:
|
||||||
|
* - cookieAuth: []
|
||||||
|
* - bearerAuth: []
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Site data for dashboard
|
||||||
|
* 401:
|
||||||
|
* $ref: '#/components/responses/UnauthorizedError'
|
||||||
|
*/
|
||||||
|
app.get("/api/site-dashboard-data", requireAuth({ allowApiToken: true }), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
// Get data from the first available LDAP connection
|
||||||
|
const connections = await storage.listLdapConnections();
|
||||||
|
|
||||||
|
if (connections.length === 0) {
|
||||||
|
return res.status(200).json({ data: [], message: "No LDAP connections available" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectionId = connections[0].id;
|
||||||
|
const sites = await storage.listAdSites(connectionId);
|
||||||
|
|
||||||
|
// Add objectType to make filtering easier in the dashboard
|
||||||
|
const result = sites.map(site => ({
|
||||||
|
...site,
|
||||||
|
objectType: 'site'
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ data: result });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/subnet-dashboard-data:
|
||||||
|
* get:
|
||||||
|
* summary: Get subnet data for dashboard visualizations
|
||||||
|
* tags: [Dashboard]
|
||||||
|
* security:
|
||||||
|
* - cookieAuth: []
|
||||||
|
* - bearerAuth: []
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Subnet data for dashboard
|
||||||
|
* 401:
|
||||||
|
* $ref: '#/components/responses/UnauthorizedError'
|
||||||
|
*/
|
||||||
|
app.get("/api/subnet-dashboard-data", requireAuth({ allowApiToken: true }), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
// Get data from the first available LDAP connection
|
||||||
|
const connections = await storage.listLdapConnections();
|
||||||
|
|
||||||
|
if (connections.length === 0) {
|
||||||
|
return res.status(200).json({ data: [], message: "No LDAP connections available" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectionId = connections[0].id;
|
||||||
|
const subnets = await storage.listAdSubnets(connectionId);
|
||||||
|
|
||||||
|
// Add objectType to make filtering easier in the dashboard
|
||||||
|
const result = subnets.map(subnet => ({
|
||||||
|
...subnet,
|
||||||
|
objectType: 'subnet'
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ data: result });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
* /api/dashboard-summary:
|
* /api/dashboard-summary:
|
||||||
|
|||||||
Reference in New Issue
Block a user