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\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());
|
||||||
|
|
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 {
|
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({
|
||||||
|
|
|
@ -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(() => {
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in a new issue