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:
parent
9726a0de46
commit
f30dab053b
6 changed files with 152 additions and 75 deletions
|
@ -5,6 +5,8 @@ namespace Pterodactyl\Http\Controllers\Api\Client;
|
|||
use Pterodactyl\Models\Server;
|
||||
use Pterodactyl\Models\Permission;
|
||||
use Spatie\QueryBuilder\QueryBuilder;
|
||||
use Spatie\QueryBuilder\AllowedFilter;
|
||||
use Pterodactyl\Models\Filters\MultiFieldServerFilter;
|
||||
use Pterodactyl\Repositories\Eloquent\ServerRepository;
|
||||
use Pterodactyl\Transformers\Api\Client\ServerTransformer;
|
||||
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.
|
||||
$builder = QueryBuilder::for(
|
||||
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
|
||||
// just return all of the servers the user has access to because they are the owner or a subuser of the
|
||||
// server.
|
||||
if ($request->input('type') === 'admin') {
|
||||
$builder = $user->root_admin
|
||||
? $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
|
||||
// make it a query that will never return any results back.
|
||||
: $builder->whereRaw('1 = 2');
|
||||
} elseif ($request->input('type') === 'owner') {
|
||||
$builder = $builder->where('owner_id', $user->id);
|
||||
// server. If ?type=admin-all is passed all servers on the system will be returned to the user, rather
|
||||
// than only servers they can see because they are an admin.
|
||||
if (in_array($type, ['admin', 'admin-all'])) {
|
||||
// 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.
|
||||
if (! $user->root_admin) {
|
||||
$builder->whereRaw('1 = 2');
|
||||
} else {
|
||||
$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 {
|
||||
$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());
|
||||
|
|
74
app/Models/Filters/MultiFieldServerFilter.php
Normal file
74
app/Models/Filters/MultiFieldServerFilter.php
Normal 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%"]);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -4,16 +4,15 @@ import http, { getPaginationSet, PaginatedResult } from '@/api/http';
|
|||
interface QueryParams {
|
||||
query?: string;
|
||||
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) => {
|
||||
http.get('/api/client', {
|
||||
params: {
|
||||
type: onlyAdmin ? 'admin' : undefined,
|
||||
'filter[name]': query,
|
||||
page,
|
||||
'filter[*]': query,
|
||||
...params,
|
||||
},
|
||||
})
|
||||
.then(({ data }) => resolve({
|
||||
|
|
|
@ -21,7 +21,7 @@ export default () => {
|
|||
|
||||
const { data: servers, error } = useSWR<PaginatedResult<Server>>(
|
||||
[ '/api/client/servers', showOnlyAdmin, page ],
|
||||
() => getServers({ onlyAdmin: showOnlyAdmin, page }),
|
||||
() => getServers({ page, type: showOnlyAdmin ? 'admin' : undefined }),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
@ -47,23 +47,21 @@ const SearchWatcher = () => {
|
|||
|
||||
export default ({ ...props }: Props) => {
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const [ loading, setLoading ] = useState(false);
|
||||
const [ servers, setServers ] = useState<Server[]>([]);
|
||||
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>) => {
|
||||
setLoading(true);
|
||||
setSubmitting(false);
|
||||
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)))
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
addError({ key: 'search', message: httpErrorToHuman(error) });
|
||||
clearAndAddHttpError({ key: 'search', error });
|
||||
})
|
||||
.then(() => setLoading(false))
|
||||
.then(() => setSubmitting(false))
|
||||
.then(() => ref.current?.focus());
|
||||
}, 500);
|
||||
|
||||
|
@ -74,7 +72,7 @@ export default ({ ...props }: Props) => {
|
|||
}, [ props.visible ]);
|
||||
|
||||
// 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 (
|
||||
<Formik
|
||||
|
@ -84,53 +82,51 @@ export default ({ ...props }: Props) => {
|
|||
})}
|
||||
initialValues={{ term: '' } as Values}
|
||||
>
|
||||
<Modal {...props}>
|
||||
<Form>
|
||||
<FormikFieldWrapper
|
||||
name={'term'}
|
||||
label={'Search term'}
|
||||
description={
|
||||
isAdmin
|
||||
? 'Enter a server name, user email, or uuid to begin searching.'
|
||||
: 'Enter a server name to begin searching.'
|
||||
}
|
||||
>
|
||||
<SearchWatcher/>
|
||||
<InputSpinner visible={loading}>
|
||||
<Field as={InputWithRef} name={'term'}/>
|
||||
</InputSpinner>
|
||||
</FormikFieldWrapper>
|
||||
</Form>
|
||||
{servers.length > 0 &&
|
||||
<div css={tw`mt-6`}>
|
||||
{
|
||||
servers.map(server => (
|
||||
<ServerResult
|
||||
key={server.uuid}
|
||||
to={`/server/${server.id}`}
|
||||
onClick={() => props.onDismissed()}
|
||||
>
|
||||
<div>
|
||||
<p css={tw`text-sm`}>{server.name}</p>
|
||||
<p css={tw`mt-1 text-xs text-neutral-400`}>
|
||||
{
|
||||
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`}>
|
||||
{({ isSubmitting }) => (
|
||||
<Modal {...props}>
|
||||
<Form>
|
||||
<FormikFieldWrapper
|
||||
name={'term'}
|
||||
label={'Search term'}
|
||||
description={'Enter a server name, uuid, or allocation to begin searching.'}
|
||||
>
|
||||
<SearchWatcher/>
|
||||
<InputSpinner visible={isSubmitting}>
|
||||
<Field as={InputWithRef} name={'term'}/>
|
||||
</InputSpinner>
|
||||
</FormikFieldWrapper>
|
||||
</Form>
|
||||
{servers.length > 0 &&
|
||||
<div css={tw`mt-6`}>
|
||||
{
|
||||
servers.map(server => (
|
||||
<ServerResult
|
||||
key={server.uuid}
|
||||
to={`/server/${server.id}`}
|
||||
onClick={() => props.onDismissed()}
|
||||
>
|
||||
<div>
|
||||
<p css={tw`text-sm`}>{server.name}</p>
|
||||
<p css={tw`mt-1 text-xs text-neutral-400`}>
|
||||
{
|
||||
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`}>
|
||||
<span css={tw`text-xs py-1 px-2 bg-cyan-800 text-cyan-100 rounded`}>
|
||||
{server.node}
|
||||
</span>
|
||||
</div>
|
||||
</ServerResult>
|
||||
))
|
||||
</div>
|
||||
</ServerResult>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</Modal>
|
||||
</Modal>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -5,12 +5,7 @@ import tw from 'twin.macro';
|
|||
|
||||
const InputSpinner = ({ visible, children }: { visible: boolean, children: React.ReactNode }) => (
|
||||
<div css={tw`relative`}>
|
||||
<Fade
|
||||
appear
|
||||
unmountOnExit
|
||||
in={visible}
|
||||
timeout={150}
|
||||
>
|
||||
<Fade appear unmountOnExit in={visible} timeout={150}>
|
||||
<div css={tw`absolute right-0 h-full flex items-center justify-end pr-3`}>
|
||||
<Spinner size={'small'}/>
|
||||
</div>
|
||||
|
|
Loading…
Reference in a new issue