diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index 02c48264..71129124 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -2,8 +2,10 @@ namespace App\Http\Controllers; +use Illuminate\Database\Query\Builder; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; use Spatie\RouteAttributes\Attributes\Get; use Spatie\RouteAttributes\Attributes\Middleware; use Spatie\RouteAttributes\Attributes\Prefix; @@ -21,8 +23,65 @@ public function search(Request $request): JsonResponse $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 = [ - 'data' => [], // Replace with actual search results + 'data' => $results, // Replace with actual search results ]; return response()->json($results); diff --git a/app/Http/Controllers/ServerController.php b/app/Http/Controllers/ServerController.php index ce94cf18..b0770c96 100644 --- a/app/Http/Controllers/ServerController.php +++ b/app/Http/Controllers/ServerController.php @@ -14,7 +14,6 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\ResourceCollection; -use Illuminate\Support\Facades\URL; use Illuminate\Validation\Rule; use Inertia\Inertia; use Inertia\Response; @@ -93,18 +92,6 @@ public function switch(Server $server): RedirectResponse { $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]); } diff --git a/eslint.config.js b/eslint.config.js index a136d224..e3d7b193 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -7,38 +7,37 @@ import typescript from 'typescript-eslint'; /** @type {import('eslint').Linter.Config[]} */ export default [ - js.configs.recommended, - ...typescript.configs.recommended, - { - ...react.configs.flat.recommended, - ...react.configs.flat['jsx-runtime'], // Required for React 17+ - languageOptions: { - globals: { - ...globals.browser, - }, - }, - rules: { - 'react/react-in-jsx-scope': 'off', - 'react/prop-types': 'off', - 'react/no-unescaped-entities': 'off', - }, - settings: { - react: { - version: 'detect', - }, - }, + js.configs.recommended, + ...typescript.configs.recommended, + { + ...react.configs.flat.recommended, + ...react.configs.flat['jsx-runtime'], // Required for React 17+ + languageOptions: { + globals: { + ...globals.browser, + }, }, - { - plugins: { - 'react-hooks': reactHooks, - }, - rules: { - 'react-hooks/rules-of-hooks': 'error', - 'react-hooks/exhaustive-deps': 'warn', - }, + rules: { + 'react/react-in-jsx-scope': 'off', + 'react/prop-types': 'off', + 'react/no-unescaped-entities': 'off', }, - { - ignores: ['vendor', 'node_modules', 'public', 'bootstrap/ssr', 'tailwind.config.js'], + settings: { + 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 ]; diff --git a/resources/js/components/app-command.tsx b/resources/js/components/app-command.tsx index d60881d7..3b02f5cf 100644 --- a/resources/js/components/app-command.tsx +++ b/resources/js/components/app-command.tsx @@ -3,9 +3,25 @@ import { useEffect, useState } from 'react'; import { Button } from '@/components/ui/button'; import { CommandIcon, SearchIcon } from 'lucide-react'; 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() { const [open, setOpen] = useState(false); + const [openServer, setOpenServer] = useState(false); + const [openProject, setOpenProject] = useState(false); + const [queryText, setQueryText] = useState(''); + const [selected, setSelected] = useState('create-server'); useEffect(() => { const down = (e: KeyboardEvent) => { @@ -19,6 +35,44 @@ export default function AppCommand() { return () => document.removeEventListener('keydown', down); }, []); + const handleOpenChange = (open: boolean) => { + setOpen(open); + if (!open) { + setOpenServer(false); + setOpenProject(false); + } + }; + + const query = useQuery({ + 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 (
- - + + No results found. - - - Create server + {query.isFetching &&

Searching...

} + {query.data && query.data?.length > 0 && ( + + {query.data.map((result, index) => ( + { + 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} + {result.type} + + ))} + + )} + + + setOpenServer(true)}> + Create server + - Create project + + setOpenProject(true)}> + Create project + +
diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 781662b3..f9678c4b 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -45,12 +45,12 @@ const mainNavItems: NavItem[] = [ const footerNavItems: NavItem[] = [ { - title: 'Workers', + title: 'Horizon Dashboard', href: route('horizon.index'), icon: ListEndIcon, }, { - title: 'Logs', + title: 'Vito Logs', href: route('log-viewer.index'), icon: LogsIcon, }, diff --git a/resources/js/components/refresh.tsx b/resources/js/components/refresh.tsx index 17c79307..4a072345 100644 --- a/resources/js/components/refresh.tsx +++ b/resources/js/components/refresh.tsx @@ -42,7 +42,6 @@ export default function Refresh() { setPoll(undefined); } localStorage.setItem('refresh_interval', refreshInterval.toString()); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [refreshInterval]); return ( diff --git a/resources/js/components/site-switch.tsx b/resources/js/components/site-switch.tsx index 7c04bb1f..cdf32e82 100644 --- a/resources/js/components/site-switch.tsx +++ b/resources/js/components/site-switch.tsx @@ -30,6 +30,7 @@ export function SiteSwitch() { if (storedSite && page.props.server_sites && !page.props.server_sites.find((site) => site.id === storedSite.id)) { siteHelper.storeSite(); + setSelectedSite(null); } const handleSiteChange = (site: Site) => { diff --git a/resources/js/components/ui/command.tsx b/resources/js/components/ui/command.tsx index cbea021c..5ffcd723 100644 --- a/resources/js/components/ui/command.tsx +++ b/resources/js/components/ui/command.tsx @@ -18,11 +18,15 @@ function Command({ className, ...props }: React.ComponentProps & { title?: string; description?: string; + shouldFilter?: boolean; + value?: string; }) { return ( @@ -31,7 +35,11 @@ function CommandDialog({ {description} - + {children} diff --git a/resources/js/lib/event-bus.tsx b/resources/js/lib/event-bus.tsx new file mode 100644 index 00000000..0c769e51 --- /dev/null +++ b/resources/js/lib/event-bus.tsx @@ -0,0 +1,16 @@ +type Callback = (data: unknown) => void; + +const events: Record = {}; + +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)); + }, +}; diff --git a/resources/js/pages/monitoring/components/resource-usage-chart.tsx b/resources/js/pages/monitoring/components/resource-usage-chart.tsx index 51c47e86..f0f2d198 100644 --- a/resources/js/pages/monitoring/components/resource-usage-chart.tsx +++ b/resources/js/pages/monitoring/components/resource-usage-chart.tsx @@ -1,12 +1,11 @@ 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 { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'; import { Metric } from '@/types/metric'; import { Button } from '@/components/ui/button'; -import { router } from '@inertiajs/react'; -import { cn } from '@/lib/utils'; +import { Formatter } from 'recharts/types/component/DefaultTooltipContent'; interface Props { title: string; @@ -14,12 +13,10 @@ interface Props { dataKey: 'load' | 'memory_used' | 'disk_used'; label: string; chartData: Metric[]; - link: string; - formatter?: (value: unknown, name: unknown) => string | number; - single?: boolean; + formatter?: Formatter; } -export function ResourceUsageChart({ title, color, dataKey, label, chartData, link, formatter, single }: Props) { +export function ResourceUsageChart({ title, color, dataKey, label, chartData, formatter }: Props) { const chartConfig = { [dataKey]: { label: label, @@ -27,37 +24,37 @@ export function ResourceUsageChart({ title, color, dataKey, label, chartData, li }, } 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 (

{title}

- - {chartData.length > 0 - ? formatter - ? formatter(chartData[chartData.length - 1][dataKey], dataKey) - : chartData[chartData.length - 1][dataKey].toLocaleString() - : 'N/A'} - + {getCurrentValue()}
- {!single && ( - - )} +
- - + + - { @@ -90,7 +87,7 @@ export function ResourceUsageChart({ title, color, dataKey, label, chartData, li /> } /> - +
diff --git a/resources/js/pages/projects/components/project-form.tsx b/resources/js/pages/projects/components/project-form.tsx index 56f18236..506d7885 100644 --- a/resources/js/pages/projects/components/project-form.tsx +++ b/resources/js/pages/projects/components/project-form.tsx @@ -8,7 +8,7 @@ import { DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; -import { FormEventHandler, ReactNode, useState } from 'react'; +import { FormEventHandler, ReactNode, useEffect, useState } from 'react'; import { Button } from '@/components/ui/button'; import { LoaderCircle } from 'lucide-react'; import { useForm } from '@inertiajs/react'; @@ -19,8 +19,30 @@ import InputError from '@/components/ui/input-error'; import { Project } from '@/types/project'; import FormSuccessful from '@/components/form-successful'; -export default function ProjectForm({ project, children }: { project?: Project; children: ReactNode }) { - const [open, setOpen] = useState(false); +export default function ProjectForm({ + 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({ name: project?.name || '', @@ -42,7 +64,7 @@ export default function ProjectForm({ project, children }: { project?: Project; }; return ( - + {children} diff --git a/resources/js/pages/servers/components/create-server.tsx b/resources/js/pages/servers/components/create-server.tsx index 447000cc..e562b635 100644 --- a/resources/js/pages/servers/components/create-server.tsx +++ b/resources/js/pages/servers/components/create-server.tsx @@ -2,7 +2,7 @@ import { ClipboardCheckIcon, ClipboardIcon, LoaderCircle, TriangleAlert, WifiIco import { Button } from '@/components/ui/button'; import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'; 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 { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import InputError from '@/components/ui/input-error'; @@ -25,9 +25,31 @@ type CreateServerForm = { 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(); + 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>({ provider: 'custom', server_provider: 0, @@ -97,7 +119,7 @@ export default function CreateServer({ children }: { children: React.ReactNode } }; return ( - + {children}