Support much better server querying from frontend

Search all servers if making a query as an admin, allow searching by a more complex set of data, fix unfocus on search field when loading indicator was rendered
This commit is contained in:
Dane Everitt 2020-10-15 21:21:38 -07:00
parent 9726a0de46
commit f30dab053b
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
6 changed files with 152 additions and 75 deletions

View file

@ -5,6 +5,8 @@ namespace Pterodactyl\Http\Controllers\Api\Client;
use Pterodactyl\Models\Server; use Pterodactyl\Models\Server;
use Pterodactyl\Models\Permission; use Pterodactyl\Models\Permission;
use Spatie\QueryBuilder\QueryBuilder; use Spatie\QueryBuilder\QueryBuilder;
use Spatie\QueryBuilder\AllowedFilter;
use Pterodactyl\Models\Filters\MultiFieldServerFilter;
use Pterodactyl\Repositories\Eloquent\ServerRepository; use Pterodactyl\Repositories\Eloquent\ServerRepository;
use Pterodactyl\Transformers\Api\Client\ServerTransformer; use Pterodactyl\Transformers\Api\Client\ServerTransformer;
use Pterodactyl\Http\Requests\Api\Client\GetServersRequest; use Pterodactyl\Http\Requests\Api\Client\GetServersRequest;
@ -43,21 +45,32 @@ class ClientController extends ClientApiController
// Start the query builder and ensure we eager load any requested relationships from the request. // Start the query builder and ensure we eager load any requested relationships from the request.
$builder = QueryBuilder::for( $builder = QueryBuilder::for(
Server::query()->with($this->getIncludesForTransformer($transformer, ['node'])) Server::query()->with($this->getIncludesForTransformer($transformer, ['node']))
)->allowedFilters('uuid', 'name', 'external_id'); )->allowedFilters([
'uuid',
'name',
'external_id',
AllowedFilter::custom('*', new MultiFieldServerFilter),
]);
$type = $request->input('type');
// Either return all of the servers the user has access to because they are an admin `?type=admin` or // Either return all of the servers the user has access to because they are an admin `?type=admin` or
// just return all of the servers the user has access to because they are the owner or a subuser of the // just return all of the servers the user has access to because they are the owner or a subuser of the
// server. // server. If ?type=admin-all is passed all servers on the system will be returned to the user, rather
if ($request->input('type') === 'admin') { // than only servers they can see because they are an admin.
$builder = $user->root_admin if (in_array($type, ['admin', 'admin-all'])) {
? $builder->whereNotIn('id', $user->accessibleServers()->pluck('id')->all()) // If they aren't an admin but want all the admin servers don't fail the request, just
// If they aren't an admin but want all the admin servers don't fail the request, just // make it a query that will never return any results back.
// make it a query that will never return any results back. if (! $user->root_admin) {
: $builder->whereRaw('1 = 2'); $builder->whereRaw('1 = 2');
} elseif ($request->input('type') === 'owner') { } else {
$builder = $builder->where('owner_id', $user->id); $builder = $type === 'admin-all'
? $builder
: $builder->whereNotIn('servers.id', $user->accessibleServers()->pluck('id')->all());
}
} else if ($type === 'owner') {
$builder = $builder->where('servers.owner_id', $user->id);
} else { } else {
$builder = $builder->whereIn('id', $user->accessibleServers()->pluck('id')->all()); $builder = $builder->whereIn('servers.id', $user->accessibleServers()->pluck('id')->all());
} }
$servers = $builder->paginate(min($request->query('per_page', 50), 100))->appends($request->query()); $servers = $builder->paginate(min($request->query('per_page', 50), 100))->appends($request->query());

View file

@ -0,0 +1,74 @@
<?php
namespace Pterodactyl\Models\Filters;
use BadMethodCallException;
use Illuminate\Support\Str;
use Spatie\QueryBuilder\Filters\Filter;
use Illuminate\Database\Eloquent\Builder;
class MultiFieldServerFilter implements Filter
{
/**
* If we detect that the value matches an IPv4 address we will use a different type of filtering
* to look at the allocations.
*/
private const IPV4_REGEX = '/^(?:[0-9]{1,3}\.){3}[0-9]{1,3}(\:\d{1,5})?$/';
/**
* A multi-column filter for the servers table that allows you to pass in a single value and
* search across multiple columns. This allows us to provide a very generic search ability for
* the frontend.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param string $value
* @param string $property
*/
public function __invoke(Builder $query, $value, string $property)
{
if ($query->getQuery()->from !== 'servers') {
throw new BadMethodCallException(
'Cannot use the MultiFieldServerFilter against a non-server model.'
);
}
if (preg_match(self::IPV4_REGEX, $value) || preg_match('/^:\d{1,5}$/', $value)) {
$query
// Only select the server values, otherwise you'll end up merging the allocation and
// server objects together, resulting in incorrect behavior and returned values.
->select('servers.*')
->join('allocations', 'allocations.server_id', '=', 'servers.id')
->where(function (Builder $builder) use ($value) {
$parts = explode(':', $value);
$builder->when(
!Str::startsWith($value, ':'),
// When the string does not start with a ":" it means we're looking for an IP or IP:Port
// combo, so use a query to handle that.
function (Builder $builder) use ($parts) {
$builder->orWhere('allocations.ip', $parts[0]);
if (!is_null($parts[1] ?? null)) {
$builder->where('allocations.port', 'LIKE', "%{$parts[1]}");
}
},
// Otherwise, just try to search for that specific port in the allocations.
function (Builder $builder) use ($value) {
$builder->orWhere('allocations.port', substr($value, 1));
}
);
})
->groupBy('servers.id');
return;
}
$query
->where(function (Builder $builder) use ($value) {
$builder->where('servers.uuid', $value)
->orWhere('servers.uuid', 'LIKE', "$value%")
->orWhere('servers.uuidShort', $value)
->orWhere('servers.external_id', $value)
->orWhereRaw('LOWER(servers.name) LIKE ?', ["%$value%"]);
});
}
}

View file

@ -4,16 +4,15 @@ import http, { getPaginationSet, PaginatedResult } from '@/api/http';
interface QueryParams { interface QueryParams {
query?: string; query?: string;
page?: number; page?: number;
onlyAdmin?: boolean; type?: string;
} }
export default ({ query, page = 1, onlyAdmin = false }: QueryParams): Promise<PaginatedResult<Server>> => { export default ({ query, ...params }: QueryParams): Promise<PaginatedResult<Server>> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
http.get('/api/client', { http.get('/api/client', {
params: { params: {
type: onlyAdmin ? 'admin' : undefined, 'filter[*]': query,
'filter[name]': query, ...params,
page,
}, },
}) })
.then(({ data }) => resolve({ .then(({ data }) => resolve({

View file

@ -21,7 +21,7 @@ export default () => {
const { data: servers, error } = useSWR<PaginatedResult<Server>>( const { data: servers, error } = useSWR<PaginatedResult<Server>>(
[ '/api/client/servers', showOnlyAdmin, page ], [ '/api/client/servers', showOnlyAdmin, page ],
() => getServers({ onlyAdmin: showOnlyAdmin, page }), () => getServers({ page, type: showOnlyAdmin ? 'admin' : undefined }),
); );
useEffect(() => { useEffect(() => {

View file

@ -47,23 +47,21 @@ const SearchWatcher = () => {
export default ({ ...props }: Props) => { export default ({ ...props }: Props) => {
const ref = useRef<HTMLInputElement>(null); const ref = useRef<HTMLInputElement>(null);
const [ loading, setLoading ] = useState(false);
const [ servers, setServers ] = useState<Server[]>([]);
const isAdmin = useStoreState(state => state.user.data!.rootAdmin); const isAdmin = useStoreState(state => state.user.data!.rootAdmin);
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes); const [ servers, setServers ] = useState<Server[]>([]);
const { clearAndAddHttpError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const search = debounce(({ term }: Values, { setSubmitting }: FormikHelpers<Values>) => { const search = debounce(({ term }: Values, { setSubmitting }: FormikHelpers<Values>) => {
setLoading(true);
setSubmitting(false);
clearFlashes('search'); clearFlashes('search');
getServers({ query: term }) // if (ref.current) ref.current.focus();
getServers({ query: term, type: isAdmin ? 'admin-all' : undefined })
.then(servers => setServers(servers.items.filter((_, index) => index < 5))) .then(servers => setServers(servers.items.filter((_, index) => index < 5)))
.catch(error => { .catch(error => {
console.error(error); console.error(error);
addError({ key: 'search', message: httpErrorToHuman(error) }); clearAndAddHttpError({ key: 'search', error });
}) })
.then(() => setLoading(false)) .then(() => setSubmitting(false))
.then(() => ref.current?.focus()); .then(() => ref.current?.focus());
}, 500); }, 500);
@ -74,7 +72,7 @@ export default ({ ...props }: Props) => {
}, [ props.visible ]); }, [ props.visible ]);
// Formik does not support an innerRef on custom components. // Formik does not support an innerRef on custom components.
const InputWithRef = (props: any) => <Input {...props} ref={ref}/>; const InputWithRef = (props: any) => <Input autoFocus {...props} ref={ref}/>;
return ( return (
<Formik <Formik
@ -84,53 +82,51 @@ export default ({ ...props }: Props) => {
})} })}
initialValues={{ term: '' } as Values} initialValues={{ term: '' } as Values}
> >
<Modal {...props}> {({ isSubmitting }) => (
<Form> <Modal {...props}>
<FormikFieldWrapper <Form>
name={'term'} <FormikFieldWrapper
label={'Search term'} name={'term'}
description={ label={'Search term'}
isAdmin description={'Enter a server name, uuid, or allocation to begin searching.'}
? 'Enter a server name, user email, or uuid to begin searching.' >
: 'Enter a server name to begin searching.' <SearchWatcher/>
} <InputSpinner visible={isSubmitting}>
> <Field as={InputWithRef} name={'term'}/>
<SearchWatcher/> </InputSpinner>
<InputSpinner visible={loading}> </FormikFieldWrapper>
<Field as={InputWithRef} name={'term'}/> </Form>
</InputSpinner> {servers.length > 0 &&
</FormikFieldWrapper> <div css={tw`mt-6`}>
</Form> {
{servers.length > 0 && servers.map(server => (
<div css={tw`mt-6`}> <ServerResult
{ key={server.uuid}
servers.map(server => ( to={`/server/${server.id}`}
<ServerResult onClick={() => props.onDismissed()}
key={server.uuid} >
to={`/server/${server.id}`} <div>
onClick={() => props.onDismissed()} <p css={tw`text-sm`}>{server.name}</p>
> <p css={tw`mt-1 text-xs text-neutral-400`}>
<div> {
<p css={tw`text-sm`}>{server.name}</p> server.allocations.filter(alloc => alloc.isDefault).map(allocation => (
<p css={tw`mt-1 text-xs text-neutral-400`}> <span key={allocation.ip + allocation.port.toString()}>{allocation.alias || allocation.ip}:{allocation.port}</span>
{ ))
server.allocations.filter(alloc => alloc.isDefault).map(allocation => ( }
<span key={allocation.ip + allocation.port.toString()}>{allocation.alias || allocation.ip}:{allocation.port}</span> </p>
)) </div>
} <div css={tw`flex-1 text-right`}>
</p>
</div>
<div css={tw`flex-1 text-right`}>
<span css={tw`text-xs py-1 px-2 bg-cyan-800 text-cyan-100 rounded`}> <span css={tw`text-xs py-1 px-2 bg-cyan-800 text-cyan-100 rounded`}>
{server.node} {server.node}
</span> </span>
</div> </div>
</ServerResult> </ServerResult>
)) ))
}
</div>
} }
</div> </Modal>
} )}
</Modal>
</Formik> </Formik>
); );
}; };

View file

@ -5,12 +5,7 @@ import tw from 'twin.macro';
const InputSpinner = ({ visible, children }: { visible: boolean, children: React.ReactNode }) => ( const InputSpinner = ({ visible, children }: { visible: boolean, children: React.ReactNode }) => (
<div css={tw`relative`}> <div css={tw`relative`}>
<Fade <Fade appear unmountOnExit in={visible} timeout={150}>
appear
unmountOnExit
in={visible}
timeout={150}
>
<div css={tw`absolute right-0 h-full flex items-center justify-end pr-3`}> <div css={tw`absolute right-0 h-full flex items-center justify-end pr-3`}>
<Spinner size={'small'}/> <Spinner size={'small'}/>
</div> </div>