Add app command/search (#622)

This commit is contained in:
Saeed Vaziry
2025-06-22 22:58:05 +02:00
committed by GitHub
parent 5689e751af
commit dc7fa6b55c
12 changed files with 287 additions and 87 deletions

View File

@ -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);

View File

@ -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]);
} }

View File

@ -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
]; ];

View File

@ -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>

View File

@ -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,
}, },

View File

@ -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 (

View File

@ -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) => {

View File

@ -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>

View 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));
},
};

View File

@ -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>

View File

@ -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>

View File

@ -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>