mirror of
https://github.com/vitodeploy/vito.git
synced 2025-07-01 05:56:16 +00:00
Add app command/search (#622)
This commit is contained in:
@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Database\Query\Builder;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Spatie\RouteAttributes\Attributes\Get;
|
use Spatie\RouteAttributes\Attributes\Get;
|
||||||
use Spatie\RouteAttributes\Attributes\Middleware;
|
use Spatie\RouteAttributes\Attributes\Middleware;
|
||||||
use Spatie\RouteAttributes\Attributes\Prefix;
|
use Spatie\RouteAttributes\Attributes\Prefix;
|
||||||
@ -21,8 +23,65 @@ public function search(Request $request): JsonResponse
|
|||||||
|
|
||||||
$query = $request->input('query');
|
$query = $request->input('query');
|
||||||
|
|
||||||
|
$projects = DB::table('projects')
|
||||||
|
->select(
|
||||||
|
DB::raw('projects.id as id'),
|
||||||
|
DB::raw('null as parent_id'),
|
||||||
|
DB::raw('projects.name as label'),
|
||||||
|
DB::raw('"project" as type')
|
||||||
|
)
|
||||||
|
->where(function (Builder $query) {
|
||||||
|
if (! user()->isAdmin()) {
|
||||||
|
$query
|
||||||
|
->join('user_project', 'projects.id', '=', 'user_project.project_id')
|
||||||
|
->where('user_project.user_id', user()->id);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->where('projects.name', 'like', "%{$query}%");
|
||||||
|
|
||||||
|
$servers = DB::table('servers')
|
||||||
|
->select(
|
||||||
|
DB::raw('servers.id as id'),
|
||||||
|
DB::raw('null as parent_id'),
|
||||||
|
DB::raw('servers.name as label'),
|
||||||
|
DB::raw('"server" as type')
|
||||||
|
)
|
||||||
|
->join('projects', 'servers.project_id', '=', 'projects.id')
|
||||||
|
->where(function (Builder $query) {
|
||||||
|
if (! user()->isAdmin()) {
|
||||||
|
$query
|
||||||
|
->join('user_project', 'projects.id', '=', 'user_project.project_id')
|
||||||
|
->where('user_project.user_id', user()->id);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->where('servers.name', 'like', "%{$query}%");
|
||||||
|
|
||||||
|
$sites = DB::table('sites')
|
||||||
|
->select(
|
||||||
|
DB::raw('sites.id as id'),
|
||||||
|
DB::raw('sites.server_id as parent_id'),
|
||||||
|
DB::raw('sites.domain as label'),
|
||||||
|
DB::raw('"site" as type')
|
||||||
|
)
|
||||||
|
->join('servers', 'sites.server_id', '=', 'servers.id')
|
||||||
|
->join('projects', 'servers.project_id', '=', 'projects.id')
|
||||||
|
->where(function (Builder $query) {
|
||||||
|
if (! user()->isAdmin()) {
|
||||||
|
$query
|
||||||
|
->join('user_project', 'projects.id', '=', 'user_project.project_id')
|
||||||
|
->where('user_project.user_id', user()->id);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->where('sites.domain', 'like', "%{$query}%");
|
||||||
|
|
||||||
|
// Combine with unionAll
|
||||||
|
$results = $projects
|
||||||
|
->unionAll($servers)
|
||||||
|
->unionAll($sites)
|
||||||
|
->get();
|
||||||
|
|
||||||
$results = [
|
$results = [
|
||||||
'data' => [], // Replace with actual search results
|
'data' => $results, // Replace with actual search results
|
||||||
];
|
];
|
||||||
|
|
||||||
return response()->json($results);
|
return response()->json($results);
|
||||||
|
@ -14,7 +14,6 @@
|
|||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||||
use Illuminate\Support\Facades\URL;
|
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
@ -93,18 +92,6 @@ public function switch(Server $server): RedirectResponse
|
|||||||
{
|
{
|
||||||
$this->authorize('view', $server);
|
$this->authorize('view', $server);
|
||||||
|
|
||||||
$previousUrl = URL::previous();
|
|
||||||
$previousRequest = Request::create($previousUrl);
|
|
||||||
$previousRoute = app('router')->getRoutes()->match($previousRequest);
|
|
||||||
|
|
||||||
if ($previousRoute->hasParameter('server')) {
|
|
||||||
if (count($previousRoute->parameters()) > 1) {
|
|
||||||
return redirect()->route('servers.show', ['server' => $server->id]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect()->route($previousRoute->getName(), ['server' => $server]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect()->route('servers.show', ['server' => $server->id]);
|
return redirect()->route('servers.show', ['server' => $server->id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,38 +7,37 @@ import typescript from 'typescript-eslint';
|
|||||||
|
|
||||||
/** @type {import('eslint').Linter.Config[]} */
|
/** @type {import('eslint').Linter.Config[]} */
|
||||||
export default [
|
export default [
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
...typescript.configs.recommended,
|
...typescript.configs.recommended,
|
||||||
{
|
{
|
||||||
...react.configs.flat.recommended,
|
...react.configs.flat.recommended,
|
||||||
...react.configs.flat['jsx-runtime'], // Required for React 17+
|
...react.configs.flat['jsx-runtime'], // Required for React 17+
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
globals: {
|
globals: {
|
||||||
...globals.browser,
|
...globals.browser,
|
||||||
},
|
},
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
'react/react-in-jsx-scope': 'off',
|
|
||||||
'react/prop-types': 'off',
|
|
||||||
'react/no-unescaped-entities': 'off',
|
|
||||||
},
|
|
||||||
settings: {
|
|
||||||
react: {
|
|
||||||
version: 'detect',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
rules: {
|
||||||
plugins: {
|
'react/react-in-jsx-scope': 'off',
|
||||||
'react-hooks': reactHooks,
|
'react/prop-types': 'off',
|
||||||
},
|
'react/no-unescaped-entities': 'off',
|
||||||
rules: {
|
|
||||||
'react-hooks/rules-of-hooks': 'error',
|
|
||||||
'react-hooks/exhaustive-deps': 'warn',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
settings: {
|
||||||
ignores: ['vendor', 'node_modules', 'public', 'bootstrap/ssr', 'tailwind.config.js'],
|
react: {
|
||||||
|
version: 'detect',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
prettier, // Turn off all rules that might conflict with Prettier
|
},
|
||||||
|
{
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'react-hooks/rules-of-hooks': 'error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ignores: ['vendor', 'node_modules', 'public', 'bootstrap/ssr', 'tailwind.config.js'],
|
||||||
|
},
|
||||||
|
prettier, // Turn off all rules that might conflict with Prettier
|
||||||
];
|
];
|
||||||
|
@ -3,9 +3,25 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { CommandIcon, SearchIcon } from 'lucide-react';
|
import { CommandIcon, SearchIcon } from 'lucide-react';
|
||||||
import CreateServer from '@/pages/servers/components/create-server';
|
import CreateServer from '@/pages/servers/components/create-server';
|
||||||
|
import ProjectForm from '@/pages/projects/components/project-form';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { router } from '@inertiajs/react';
|
||||||
|
|
||||||
|
type SearchResult = {
|
||||||
|
id: number;
|
||||||
|
parent_id?: number;
|
||||||
|
label: string;
|
||||||
|
type: 'server' | 'project' | 'site';
|
||||||
|
};
|
||||||
|
|
||||||
export default function AppCommand() {
|
export default function AppCommand() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [openServer, setOpenServer] = useState(false);
|
||||||
|
const [openProject, setOpenProject] = useState(false);
|
||||||
|
const [queryText, setQueryText] = useState('');
|
||||||
|
const [selected, setSelected] = useState<string>('create-server');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const down = (e: KeyboardEvent) => {
|
const down = (e: KeyboardEvent) => {
|
||||||
@ -19,6 +35,44 @@ export default function AppCommand() {
|
|||||||
return () => document.removeEventListener('keydown', down);
|
return () => document.removeEventListener('keydown', down);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleOpenChange = (open: boolean) => {
|
||||||
|
setOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setOpenServer(false);
|
||||||
|
setOpenProject(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const query = useQuery<SearchResult[]>({
|
||||||
|
queryKey: ['search'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await axios.get(route('search', { query: queryText }));
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
retry: false,
|
||||||
|
enabled: false,
|
||||||
|
refetchInterval: false,
|
||||||
|
refetchIntervalInBackground: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (query.data && query.data.length > 0) {
|
||||||
|
setSelected(`result-0`);
|
||||||
|
} else {
|
||||||
|
setSelected('create-server');
|
||||||
|
}
|
||||||
|
}, [query.data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (queryText !== '' && queryText.length >= 3) {
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
query.refetch();
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}, [queryText]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Button className="hidden px-1! lg:flex" variant="outline" size="sm" onClick={() => setOpen(true)}>
|
<Button className="hidden px-1! lg:flex" variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||||
@ -32,15 +86,51 @@ export default function AppCommand() {
|
|||||||
<Button className="lg:hidden" variant="outline" size="sm" onClick={() => setOpen(true)}>
|
<Button className="lg:hidden" variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||||
<CommandIcon className="mr-1 size-3" /> K
|
<CommandIcon className="mr-1 size-3" /> K
|
||||||
</Button>
|
</Button>
|
||||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
<CommandDialog open={open} onOpenChange={handleOpenChange} shouldFilter={false} value={selected}>
|
||||||
<CommandInput placeholder="Type a command or search..." />
|
<CommandInput placeholder="Type a command or search..." onValueChange={setQueryText} />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>No results found.</CommandEmpty>
|
<CommandEmpty>No results found.</CommandEmpty>
|
||||||
<CommandGroup heading="Suggestions">
|
{query.isFetching && <p className="text-muted-foreground p-4 text-sm">Searching...</p>}
|
||||||
<CreateServer>
|
{query.data && query.data?.length > 0 && (
|
||||||
<CommandItem>Create server</CommandItem>
|
<CommandGroup heading="Search Results">
|
||||||
|
{query.data.map((result, index) => (
|
||||||
|
<CommandItem
|
||||||
|
key={`search-result-${result.id}`}
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
value={`result-${index}`}
|
||||||
|
onSelect={() => {
|
||||||
|
if (result.type === 'server') {
|
||||||
|
router.post(route('servers.switch', { server: result.id }));
|
||||||
|
} else if (result.type === 'project') {
|
||||||
|
router.patch(
|
||||||
|
route('projects.switch', {
|
||||||
|
project: result.id,
|
||||||
|
currentPath: window.location.pathname,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else if (result.type === 'site') {
|
||||||
|
router.post(route('sites.switch', { server: result.parent_id, site: result.id }));
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{result.label}
|
||||||
|
<Badge variant="outline">{result.type}</Badge>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
<CommandGroup heading="Commands">
|
||||||
|
<CreateServer defaultOpen={openServer} onOpenChange={setOpenServer}>
|
||||||
|
<CommandItem value="create-server" key="cmd-create-server" onSelect={() => setOpenServer(true)}>
|
||||||
|
Create server
|
||||||
|
</CommandItem>
|
||||||
</CreateServer>
|
</CreateServer>
|
||||||
<CommandItem>Create project</CommandItem>
|
<ProjectForm defaultOpen={openProject} onOpenChange={setOpenProject}>
|
||||||
|
<CommandItem value="create-project" key="cmd-create-project" onSelect={() => setOpenProject(true)}>
|
||||||
|
Create project
|
||||||
|
</CommandItem>
|
||||||
|
</ProjectForm>
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
</CommandList>
|
</CommandList>
|
||||||
</CommandDialog>
|
</CommandDialog>
|
||||||
|
@ -45,12 +45,12 @@ const mainNavItems: NavItem[] = [
|
|||||||
|
|
||||||
const footerNavItems: NavItem[] = [
|
const footerNavItems: NavItem[] = [
|
||||||
{
|
{
|
||||||
title: 'Workers',
|
title: 'Horizon Dashboard',
|
||||||
href: route('horizon.index'),
|
href: route('horizon.index'),
|
||||||
icon: ListEndIcon,
|
icon: ListEndIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Logs',
|
title: 'Vito Logs',
|
||||||
href: route('log-viewer.index'),
|
href: route('log-viewer.index'),
|
||||||
icon: LogsIcon,
|
icon: LogsIcon,
|
||||||
},
|
},
|
||||||
|
@ -42,7 +42,6 @@ export default function Refresh() {
|
|||||||
setPoll(undefined);
|
setPoll(undefined);
|
||||||
}
|
}
|
||||||
localStorage.setItem('refresh_interval', refreshInterval.toString());
|
localStorage.setItem('refresh_interval', refreshInterval.toString());
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [refreshInterval]);
|
}, [refreshInterval]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -30,6 +30,7 @@ export function SiteSwitch() {
|
|||||||
|
|
||||||
if (storedSite && page.props.server_sites && !page.props.server_sites.find((site) => site.id === storedSite.id)) {
|
if (storedSite && page.props.server_sites && !page.props.server_sites.find((site) => site.id === storedSite.id)) {
|
||||||
siteHelper.storeSite();
|
siteHelper.storeSite();
|
||||||
|
setSelectedSite(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSiteChange = (site: Site) => {
|
const handleSiteChange = (site: Site) => {
|
||||||
|
@ -18,11 +18,15 @@ function Command({ className, ...props }: React.ComponentProps<typeof CommandPri
|
|||||||
function CommandDialog({
|
function CommandDialog({
|
||||||
title = 'Command Palette',
|
title = 'Command Palette',
|
||||||
description = 'Search for a command to run...',
|
description = 'Search for a command to run...',
|
||||||
|
shouldFilter = true,
|
||||||
|
value,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof Dialog> & {
|
}: React.ComponentProps<typeof Dialog> & {
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
shouldFilter?: boolean;
|
||||||
|
value?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Dialog {...props}>
|
<Dialog {...props}>
|
||||||
@ -31,7 +35,11 @@ function CommandDialog({
|
|||||||
<DialogDescription>{description}</DialogDescription>
|
<DialogDescription>{description}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogContent className="overflow-hidden p-0">
|
<DialogContent className="overflow-hidden p-0">
|
||||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
<Command
|
||||||
|
shouldFilter={shouldFilter}
|
||||||
|
value={value}
|
||||||
|
className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</Command>
|
</Command>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
16
resources/js/lib/event-bus.tsx
Normal file
16
resources/js/lib/event-bus.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
type Callback = (data: unknown) => void;
|
||||||
|
|
||||||
|
const events: Record<string, Callback[]> = {};
|
||||||
|
|
||||||
|
export const EventBus = {
|
||||||
|
on(event: string, callback: Callback) {
|
||||||
|
if (!events[event]) events[event] = [];
|
||||||
|
events[event].push(callback);
|
||||||
|
},
|
||||||
|
off(event: string, callback: Callback) {
|
||||||
|
events[event] = events[event]?.filter((cb) => cb !== callback) || [];
|
||||||
|
},
|
||||||
|
emit(event: string, data?: unknown) {
|
||||||
|
events[event]?.forEach((cb) => cb(data));
|
||||||
|
},
|
||||||
|
};
|
@ -1,12 +1,11 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Area, AreaChart, XAxis, YAxis } from 'recharts';
|
import { Area, AreaChart, XAxis } from 'recharts';
|
||||||
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart';
|
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart';
|
||||||
import { Metric } from '@/types/metric';
|
import { Metric } from '@/types/metric';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { router } from '@inertiajs/react';
|
import { Formatter } from 'recharts/types/component/DefaultTooltipContent';
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
@ -14,12 +13,10 @@ interface Props {
|
|||||||
dataKey: 'load' | 'memory_used' | 'disk_used';
|
dataKey: 'load' | 'memory_used' | 'disk_used';
|
||||||
label: string;
|
label: string;
|
||||||
chartData: Metric[];
|
chartData: Metric[];
|
||||||
link: string;
|
formatter?: Formatter<number | string, string>;
|
||||||
formatter?: (value: unknown, name: unknown) => string | number;
|
|
||||||
single?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ResourceUsageChart({ title, color, dataKey, label, chartData, link, formatter, single }: Props) {
|
export function ResourceUsageChart({ title, color, dataKey, label, chartData, formatter }: Props) {
|
||||||
const chartConfig = {
|
const chartConfig = {
|
||||||
[dataKey]: {
|
[dataKey]: {
|
||||||
label: label,
|
label: label,
|
||||||
@ -27,37 +24,37 @@ export function ResourceUsageChart({ title, color, dataKey, label, chartData, li
|
|||||||
},
|
},
|
||||||
} satisfies ChartConfig;
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
|
const getCurrentValue = () => {
|
||||||
|
if (chartData.length === 0) return 'N/A';
|
||||||
|
|
||||||
|
const value = chartData[chartData.length - 1][dataKey];
|
||||||
|
if (formatter) {
|
||||||
|
return formatter(value, dataKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof value === 'number' ? value.toLocaleString() : String(value);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="overflow-hidden p-0">
|
<CardContent className="overflow-hidden p-0">
|
||||||
<div className="flex items-start justify-between p-4">
|
<div className="flex items-start justify-between p-4">
|
||||||
<div className="space-y-2 py-[7px]">
|
<div className="space-y-2 py-[7px]">
|
||||||
<h2 className="text-muted-foreground text-sm">{title}</h2>
|
<h2 className="text-muted-foreground text-sm">{title}</h2>
|
||||||
<span className="text-3xl font-bold">
|
<span className="text-3xl font-bold">{getCurrentValue()}</span>
|
||||||
{chartData.length > 0
|
|
||||||
? formatter
|
|
||||||
? formatter(chartData[chartData.length - 1][dataKey], dataKey)
|
|
||||||
: chartData[chartData.length - 1][dataKey].toLocaleString()
|
|
||||||
: 'N/A'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{!single && (
|
<Button variant="ghost">View</Button>
|
||||||
<Button variant="ghost" onClick={() => router.visit(link)}>
|
|
||||||
View
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<ChartContainer config={chartConfig} className={cn('aspect-auto w-full overflow-hidden rounded-b-xl', single ? 'h-[400px]' : 'h-[100px]')}>
|
<ChartContainer config={chartConfig} className="aspect-auto h-[100px] w-full overflow-hidden rounded-b-xl">
|
||||||
<AreaChart accessibilityLayer data={chartData} margin={{ left: 0, right: 0, top: 0, bottom: 0 }}>
|
<AreaChart data={chartData} margin={{ left: 0, right: 0, top: 0, bottom: 0 }}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id={`fill-${dataKey}`} x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id={`fill-${dataKey}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor={color} stopOpacity={0.8} />
|
<stop offset="5%" stopColor={color} stopOpacity={0.8} />
|
||||||
<stop offset="95%" stopColor={color} stopOpacity={0.1} />
|
<stop offset="95%" stopColor={color} stopOpacity={0.1} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<YAxis dataKey={dataKey} hide />
|
|
||||||
<XAxis
|
<XAxis
|
||||||
hide={!single}
|
hide
|
||||||
dataKey="date"
|
dataKey="date"
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
@ -74,7 +71,7 @@ export function ResourceUsageChart({ title, color, dataKey, label, chartData, li
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
cursor={true}
|
cursor={false}
|
||||||
content={
|
content={
|
||||||
<ChartTooltipContent
|
<ChartTooltipContent
|
||||||
labelFormatter={(value) => {
|
labelFormatter={(value) => {
|
||||||
@ -90,7 +87,7 @@ export function ResourceUsageChart({ title, color, dataKey, label, chartData, li
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Area dataKey={dataKey} type="monotone" fill={`url(#fill-${dataKey})`} stroke={color} />
|
<Area dataKey={dataKey} type="natural" fill={`url(#fill-${dataKey})`} stroke={color} />
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
@ -8,7 +8,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { FormEventHandler, ReactNode, useState } from 'react';
|
import { FormEventHandler, ReactNode, useEffect, useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { LoaderCircle } from 'lucide-react';
|
import { LoaderCircle } from 'lucide-react';
|
||||||
import { useForm } from '@inertiajs/react';
|
import { useForm } from '@inertiajs/react';
|
||||||
@ -19,8 +19,30 @@ import InputError from '@/components/ui/input-error';
|
|||||||
import { Project } from '@/types/project';
|
import { Project } from '@/types/project';
|
||||||
import FormSuccessful from '@/components/form-successful';
|
import FormSuccessful from '@/components/form-successful';
|
||||||
|
|
||||||
export default function ProjectForm({ project, children }: { project?: Project; children: ReactNode }) {
|
export default function ProjectForm({
|
||||||
const [open, setOpen] = useState(false);
|
project,
|
||||||
|
defaultOpen,
|
||||||
|
onOpenChange,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
project?: Project;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(defaultOpen || false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (defaultOpen) {
|
||||||
|
setOpen(defaultOpen);
|
||||||
|
}
|
||||||
|
}, [setOpen, defaultOpen]);
|
||||||
|
|
||||||
|
const handleOpenChange = (open: boolean) => {
|
||||||
|
setOpen(open);
|
||||||
|
if (onOpenChange) {
|
||||||
|
onOpenChange(open);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
name: project?.name || '',
|
name: project?.name || '',
|
||||||
@ -42,7 +64,7 @@ export default function ProjectForm({ project, children }: { project?: Project;
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
@ -2,7 +2,7 @@ import { ClipboardCheckIcon, ClipboardIcon, LoaderCircle, TriangleAlert, WifiIco
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
|
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
|
||||||
import { useForm, usePage } from '@inertiajs/react';
|
import { useForm, usePage } from '@inertiajs/react';
|
||||||
import React, { FormEventHandler, useState } from 'react';
|
import React, { FormEventHandler, useEffect, useState } from 'react';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import InputError from '@/components/ui/input-error';
|
import InputError from '@/components/ui/input-error';
|
||||||
@ -25,9 +25,31 @@ type CreateServerForm = {
|
|||||||
plan: string;
|
plan: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CreateServer({ children }: { children: React.ReactNode }) {
|
export default function CreateServer({
|
||||||
|
defaultOpen,
|
||||||
|
onOpenChange,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
const page = usePage<SharedData>();
|
const page = usePage<SharedData>();
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(defaultOpen || false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (defaultOpen) {
|
||||||
|
setOpen(defaultOpen);
|
||||||
|
}
|
||||||
|
}, [defaultOpen]);
|
||||||
|
|
||||||
|
const handleOpenChange = (open: boolean) => {
|
||||||
|
setOpen(open);
|
||||||
|
if (onOpenChange) {
|
||||||
|
onOpenChange(open);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const form = useForm<Required<CreateServerForm>>({
|
const form = useForm<Required<CreateServerForm>>({
|
||||||
provider: 'custom',
|
provider: 'custom',
|
||||||
server_provider: 0,
|
server_provider: 0,
|
||||||
@ -97,7 +119,7 @@ export default function CreateServer({ children }: { children: React.ReactNode }
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet>
|
<Sheet open={open} onOpenChange={handleOpenChange} modal>
|
||||||
<SheetTrigger asChild>{children}</SheetTrigger>
|
<SheetTrigger asChild>{children}</SheetTrigger>
|
||||||
<SheetContent className="w-full lg:max-w-4xl">
|
<SheetContent className="w-full lg:max-w-4xl">
|
||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
|
Reference in New Issue
Block a user