Add project scaffolding and documentation
Add .env.example with configuration for database, Redis, security, SMTP, workers, and plugins. Add .gitignore for Python, Node.js, Next.js, Docker volumes, and IDE files. Add MIT License. Update README.md with feature overview, quick start guide, architecture description, plugin system documentation, security details, backup/restore instructions, and developer setup. Add Alembic configuration files and placeholder directories for API, web, worker, and plugin components
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label, Switch } from "@nexadash/ui";
|
||||
|
||||
import { connectionApi, pluginApi } from "@/lib/api";
|
||||
|
||||
export default function NewConnectionPage() {
|
||||
const router = useRouter();
|
||||
const [pluginId, setPluginId] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
const [verifyTls, setVerifyTls] = useState(true);
|
||||
const [credentials, setCredentials] = useState("");
|
||||
const [testResult, setTestResult] = useState<any>(null);
|
||||
|
||||
const { data: plugins } = useQuery({
|
||||
queryKey: ["plugins"],
|
||||
queryFn: () => pluginApi.list().then((r) => r.data),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (payload: any) => connectionApi.create(payload),
|
||||
onSuccess: () => router.push("/plugins"),
|
||||
});
|
||||
|
||||
const testMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
connectionApi.test({
|
||||
plugin_id: pluginId,
|
||||
base_url: baseUrl,
|
||||
verify_tls: verifyTls,
|
||||
credentials: credentials ? JSON.parse(credentials) : {},
|
||||
}),
|
||||
onSuccess: (res) => setTestResult(res.data),
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
createMutation.mutate({
|
||||
name,
|
||||
plugin_id: pluginId,
|
||||
base_url: baseUrl,
|
||||
verify_tls: verifyTls,
|
||||
credentials: credentials ? JSON.parse(credentials) : {},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl p-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>New Service Connection</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="plugin">Plugin</Label>
|
||||
<select
|
||||
id="plugin"
|
||||
required
|
||||
value={pluginId}
|
||||
onChange={(e) => setPluginId(e.target.value)}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm"
|
||||
>
|
||||
<option value="">Select plugin</option>
|
||||
{plugins?.map((p: any) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input id="name" required value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="baseUrl">Base URL</Label>
|
||||
<Input id="baseUrl" required value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch id="verifyTls" checked={verifyTls} onCheckedChange={setVerifyTls} />
|
||||
<Label htmlFor="verifyTls">Verify TLS</Label>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="credentials">Credentials (JSON)</Label>
|
||||
<textarea
|
||||
id="credentials"
|
||||
value={credentials}
|
||||
onChange={(e) => setCredentials(e.target.value)}
|
||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm"
|
||||
placeholder='{"api_token": "..."}'
|
||||
/>
|
||||
</div>
|
||||
{testResult && (
|
||||
<div className="rounded-md border p-3 text-sm">
|
||||
Test: {testResult.success ? "OK" : "Failed"} ({testResult.status_code || "-"})
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => testMutation.mutate()}>
|
||||
Test
|
||||
</Button>
|
||||
<Button type="submit" className="flex-1" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { Button, Card, CardContent, CardHeader, CardTitle, Skeleton } from "@nexadash/ui";
|
||||
|
||||
import { authApi, dashboardApi } from "@/lib/api";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const { data: user, isLoading: userLoading } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: () => authApi.me().then((r) => r.data),
|
||||
retry: false,
|
||||
});
|
||||
const { data: dashboards, isLoading: dashboardsLoading } = useQuery({
|
||||
queryKey: ["dashboards"],
|
||||
queryFn: () => dashboardApi.list().then((r) => r.data),
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!userLoading && !user) {
|
||||
router.replace("/login");
|
||||
}
|
||||
}, [user, userLoading, router]);
|
||||
|
||||
if (userLoading || dashboardsLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Skeleton className="h-32" />
|
||||
<Skeleton className="h-32" />
|
||||
<Skeleton className="h-32" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Dashboards</h1>
|
||||
<p className="text-muted-foreground">Welcome back, {user?.first_name || user?.email}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={() => router.push("/dashboards/new")}>Create Dashboard</Button>
|
||||
<Button variant="outline" onClick={() => { logout(); router.replace("/login"); }}>
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{dashboards?.map((dashboard: any) => (
|
||||
<Card
|
||||
key={dashboard.id}
|
||||
className="cursor-pointer transition hover:shadow-md"
|
||||
onClick={() => router.push(`/dashboards/${dashboard.id}`)}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>{dashboard.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{dashboard.widgets?.length || 0} widgets
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{dashboards?.length === 0 && (
|
||||
<div className="col-span-full text-center text-muted-foreground">
|
||||
No dashboards yet. Create one to get started.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
||||
import { Button, Skeleton } from "@nexadash/ui";
|
||||
|
||||
import { dashboardApi } from "@/lib/api";
|
||||
|
||||
export default function DashboardDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const id = params.id as string;
|
||||
|
||||
const { data: dashboard, isLoading } = useQuery({
|
||||
queryKey: ["dashboard", id],
|
||||
queryFn: () => dashboardApi.get(id).then((r) => r.data),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="mt-6 h-96" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{dashboard?.title}</h1>
|
||||
<p className="text-muted-foreground">{dashboard?.description}</p>
|
||||
</div>
|
||||
<Button onClick={() => router.push(`/dashboards/${id}/edit`)}>Edit Layout</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{dashboard?.widgets?.map((widget: any) => (
|
||||
<div
|
||||
key={widget.id}
|
||||
className="rounded-xl border bg-card p-4 shadow backdrop-blur-sm"
|
||||
>
|
||||
<h3 className="font-semibold">{widget.title}</h3>
|
||||
<p className="text-sm text-muted-foreground">{widget.widget_type}</p>
|
||||
</div>
|
||||
))}
|
||||
{dashboard?.widgets?.length === 0 && (
|
||||
<div className="col-span-full text-center text-muted-foreground">
|
||||
No widgets. Edit the dashboard to add widgets.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label } from "@nexadash/ui";
|
||||
|
||||
import { dashboardApi } from "@/lib/api";
|
||||
|
||||
export default function NewDashboardPage() {
|
||||
const router = useRouter();
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (payload: any) => dashboardApi.create(payload),
|
||||
onSuccess: (res) => router.push(`/dashboards/${res.data.id}`),
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
mutation.mutate({ title, description, folder: "default" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl p-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Create Dashboard</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input id="title" required value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Input id="description" value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.75rem;
|
||||
--status-ok: 142.1 76.2% 36.3%;
|
||||
--status-warning: 37.7 92.1% 50.2%;
|
||||
--status-error: 0 72.2% 50.6%;
|
||||
--status-info: 217.2 91.2% 59.8%;
|
||||
--status-unknown: 215.4 16.3% 46.9%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 7.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 7.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 217.2 91.2% 59.8%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
--status-ok: 142.1 70.6% 45.3%;
|
||||
--status-warning: 37.7 92.1% 50.2%;
|
||||
--status-error: 0 72.2% 50.6%;
|
||||
--status-info: 217.2 91.2% 59.8%;
|
||||
--status-unknown: 215.4 16.3% 46.9%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
|
||||
import { Providers } from "@/components/providers";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "NexaDash",
|
||||
description: "Modern plugin-based dashboard",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={`${inter.variable} font-sans antialiased`}>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<Providers>{children}</Providers>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label } from "@nexadash/ui";
|
||||
|
||||
import { authApi } from "@/lib/api";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const setToken = useAuthStore((s) => s.setToken);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (payload: any) => authApi.login(payload),
|
||||
onSuccess: (res) => {
|
||||
localStorage.setItem("access_token", res.data.access_token);
|
||||
localStorage.setItem("refresh_token", res.data.refresh_token);
|
||||
setToken(res.data.access_token);
|
||||
router.push("/dashboard");
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
mutation.mutate({ email, password });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-background to-muted/30 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign in to NexaDash</CardTitle>
|
||||
<CardDescription>Enter your credentials to access the dashboard.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input id="email" type="email" required value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input id="password" type="password" required value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</div>
|
||||
{mutation.isError && (
|
||||
<div className="text-sm text-destructive">Invalid credentials.</div>
|
||||
)}
|
||||
<Button type="submit" className="w-full" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Signing in..." : "Sign in"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { authApi } from "@/lib/api";
|
||||
|
||||
export default function HomePage() {
|
||||
const router = useRouter();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["setup-status"],
|
||||
queryFn: () => authApi.setupStatus().then((r) => r.data),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && data) {
|
||||
if (data.setup_required) {
|
||||
router.replace("/setup");
|
||||
} else {
|
||||
router.replace("/login");
|
||||
}
|
||||
}
|
||||
}, [data, isLoading, router]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold tracking-tight">NexaDash</h1>
|
||||
<p className="mt-2 text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Skeleton } from "@nexadash/ui";
|
||||
|
||||
import { pluginApi } from "@/lib/api";
|
||||
|
||||
export default function PluginDetailPage() {
|
||||
const params = useParams();
|
||||
const id = params.id as string;
|
||||
|
||||
const { data: plugin, isLoading } = useQuery({
|
||||
queryKey: ["plugin", id],
|
||||
queryFn: () => pluginApi.get(id).then((r) => r.data),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="mt-4 h-32" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold">{plugin?.name}</h1>
|
||||
<p className="text-muted-foreground">{plugin?.description}</p>
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Settings Schema</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="text-xs">{JSON.stringify(plugin?.settings_schema, null, 2)}</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Credentials Schema</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="text-xs">{JSON.stringify(plugin?.credentials_schema, null, 2)}</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Switch } from "@nexadash/ui";
|
||||
|
||||
import { pluginApi } from "@/lib/api";
|
||||
|
||||
export default function PluginsPage() {
|
||||
const router = useRouter();
|
||||
const { data: plugins, isLoading, refetch } = useQuery({
|
||||
queryKey: ["plugins"],
|
||||
queryFn: () => pluginApi.list().then((r) => r.data),
|
||||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: ({ id, is_active }: { id: string; is_active: boolean }) =>
|
||||
pluginApi.update(id, { is_active }),
|
||||
onSuccess: () => refetch(),
|
||||
});
|
||||
|
||||
if (isLoading) return <div className="p-6">Loading plugins...</div>;
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Plugins</h1>
|
||||
<p className="text-muted-foreground">Manage installed plugins and integrations.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => router.push("/plugins/upload")}>
|
||||
Upload Plugin
|
||||
</Button>
|
||||
<Button onClick={() => router.push("/connections/new")}>New Connection</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{plugins?.map((plugin: any) => (
|
||||
<Card key={plugin.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>{plugin.name}</CardTitle>
|
||||
<Switch
|
||||
checked={plugin.is_active}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
toggleMutation.mutate({ id: plugin.id, is_active: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<CardDescription>{plugin.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{plugin.category}</Badge>
|
||||
<Badge>{plugin.version}</Badge>
|
||||
{plugin.is_builtin && <Badge variant="secondary">built-in</Badge>}
|
||||
</div>
|
||||
<Button
|
||||
className="mt-4 w-full"
|
||||
variant="outline"
|
||||
onClick={() => router.push(`/plugins/${plugin.id}`)}
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button, Card, CardContent, CardHeader, CardTitle } from "@nexadash/ui";
|
||||
|
||||
import { pluginApi } from "@/lib/api";
|
||||
|
||||
export default function UploadPluginPage() {
|
||||
const router = useRouter();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (f: File) => pluginApi.upload(f),
|
||||
onSuccess: () => router.push("/plugins"),
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (file) mutation.mutate(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl p-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Upload Plugin</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip"
|
||||
required
|
||||
onChange={(e) => setFile(e.target.files?.[0] || null)}
|
||||
className="block w-full text-sm text-muted-foreground file:mr-4 file:rounded-md file:border-0 file:bg-primary file:px-4 file:py-2 file:text-primary-foreground"
|
||||
/>
|
||||
{mutation.isError && (
|
||||
<div className="text-sm text-destructive">Upload failed.</div>
|
||||
)}
|
||||
<Button type="submit" className="w-full" disabled={mutation.isPending || !file}>
|
||||
{mutation.isPending ? "Uploading..." : "Upload"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label } from "@nexadash/ui";
|
||||
|
||||
import { authApi } from "@/lib/api";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
export default function SetupPage() {
|
||||
const router = useRouter();
|
||||
const setToken = useAuthStore((s) => s.setToken);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [firstName, setFirstName] = useState("");
|
||||
const [lastName, setLastName] = useState("");
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["setup-status"],
|
||||
queryFn: () => authApi.setupStatus().then((r) => r.data),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data && !data.setup_required) {
|
||||
router.replace("/login");
|
||||
}
|
||||
}, [data, router]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (payload: any) => authApi.setup(payload),
|
||||
onSuccess: (res) => {
|
||||
localStorage.setItem("access_token", res.data.access_token);
|
||||
localStorage.setItem("refresh_token", res.data.refresh_token);
|
||||
setToken(res.data.access_token);
|
||||
router.push("/dashboard");
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
mutation.mutate({ email, password, first_name: firstName, last_name: lastName });
|
||||
};
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-background to-muted/30 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Welcome to NexaDash</CardTitle>
|
||||
<CardDescription>Create the owner account to continue.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="firstName">First name</Label>
|
||||
<Input id="firstName" value={firstName} onChange={(e) => setFirstName(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lastName">Last name</Label>
|
||||
<Input id="lastName" value={lastName} onChange={(e) => setLastName(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input id="email" type="email" required value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input id="password" type="password" required minLength={12} value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</div>
|
||||
{mutation.isError && (
|
||||
<div className="text-sm text-destructive">Setup failed. Please try again.</div>
|
||||
)}
|
||||
<Button type="submit" className="w-full" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Creating..." : "Create owner account"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { useState } from "react";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import axios from "axios";
|
||||
|
||||
import { API_BASE_URL } from "@nexadash/shared";
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
if (typeof window !== "undefined") {
|
||||
const token = localStorage.getItem("access_token");
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
try {
|
||||
const refresh = localStorage.getItem("refresh_token");
|
||||
if (!refresh) throw new Error("No refresh token");
|
||||
const { data } = await axios.post(`${API_BASE_URL}/api/v1/auth/refresh`, {
|
||||
refresh_token: refresh,
|
||||
});
|
||||
localStorage.setItem("access_token", data.access_token);
|
||||
localStorage.setItem("refresh_token", data.refresh_token);
|
||||
originalRequest.headers.Authorization = `Bearer ${data.access_token}`;
|
||||
return api(originalRequest);
|
||||
} catch {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export const authApi = {
|
||||
setupStatus: () => api.get("/api/v1/auth/setup-status"),
|
||||
setup: (data: any) => api.post("/api/v1/auth/setup", data),
|
||||
login: (data: any) => api.post("/api/v1/auth/login", data),
|
||||
logout: () => api.post("/api/v1/auth/logout"),
|
||||
me: () => api.get("/api/v1/users/me"),
|
||||
};
|
||||
|
||||
export const dashboardApi = {
|
||||
list: () => api.get("/api/v1/dashboards"),
|
||||
create: (data: any) => api.post("/api/v1/dashboards", data),
|
||||
get: (id: string) => api.get(`/api/v1/dashboards/${id}`),
|
||||
update: (id: string, data: any) => api.put(`/api/v1/dashboards/${id}`, data),
|
||||
delete: (id: string) => api.delete(`/api/v1/dashboards/${id}`),
|
||||
duplicate: (id: string) => api.post(`/api/v1/dashboards/${id}/duplicate`),
|
||||
updateLayout: (id: string, data: any) => api.put(`/api/v1/dashboards/${id}/layout`, data),
|
||||
};
|
||||
|
||||
export const widgetApi = {
|
||||
create: (dashboardId: string, data: any) => api.post(`/api/v1/widgets/${dashboardId}`, data),
|
||||
update: (id: string, data: any) => api.put(`/api/v1/widgets/${id}`, data),
|
||||
delete: (id: string) => api.delete(`/api/v1/widgets/${id}`),
|
||||
};
|
||||
|
||||
export const pluginApi = {
|
||||
list: () => api.get("/api/v1/plugins"),
|
||||
get: (id: string) => api.get(`/api/v1/plugins/${id}`),
|
||||
update: (id: string, data: any) => api.put(`/api/v1/plugins/${id}`, data),
|
||||
upload: (file: File) => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
return api.post("/api/v1/plugins/upload", form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
},
|
||||
createInstance: (pluginId: string, data: any) =>
|
||||
api.post(`/api/v1/plugins/${pluginId}/instances`, data),
|
||||
listInstances: (pluginId: string) => api.get(`/api/v1/plugins/${pluginId}/instances`),
|
||||
updateInstance: (id: string, data: any) => api.put(`/api/v1/plugins/instances/${id}`, data),
|
||||
deleteInstance: (id: string) => api.delete(`/api/v1/plugins/instances/${id}`),
|
||||
};
|
||||
|
||||
export const connectionApi = {
|
||||
list: () => api.get("/api/v1/connections"),
|
||||
create: (data: any) => api.post("/api/v1/connections", data),
|
||||
test: (data: any) => api.post("/api/v1/connections/test", data),
|
||||
delete: (id: string) => api.delete(`/api/v1/connections/${id}`),
|
||||
};
|
||||
|
||||
export const widgetDataApi = {
|
||||
fetch: (data: any) => api.post("/api/v1/widget-data/widget-data", data),
|
||||
healthcheck: (instanceId: string) =>
|
||||
api.post(`/api/v1/widget-data/widget-data/${instanceId}/healthcheck`),
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
import type { User } from "@nexadash/shared";
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
setUser: (user: User | null) => void;
|
||||
setToken: (token: string | null) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
user: null,
|
||||
token: null,
|
||||
setUser: (user) => set({ user }),
|
||||
setToken: (token) => set({ token }),
|
||||
logout: () => {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
set({ user: null, token: null });
|
||||
},
|
||||
}),
|
||||
{ name: "nexadash-auth" }
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
interface UIState {
|
||||
sidebarOpen: boolean;
|
||||
commandOpen: boolean;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
setCommandOpen: (open: boolean) => void;
|
||||
toggleSidebar: () => void;
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>((set) => ({
|
||||
sidebarOpen: true,
|
||||
commandOpen: false,
|
||||
setSidebarOpen: (open) => set({ sidebarOpen: open }),
|
||||
setCommandOpen: (open) => set({ commandOpen: open }),
|
||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||
}));
|
||||
Reference in New Issue
Block a user