Update admin instances dashboard

This commit is contained in:
Daniel Supernault 2023-03-19 05:29:54 -06:00
parent 419c0fb0fb
commit ecfc0766f8
No known key found for this signature in database
GPG key ID: 0DEF1C662C9033F7
9 changed files with 852 additions and 265 deletions

View file

@ -8,66 +8,13 @@ use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use App\Services\InstanceService;
use App\Http\Resources\AdminInstance;
trait AdminInstanceController
{
public function instances(Request $request)
{
$this->validate($request, [
'filter' => [
'nullable',
'string',
'min:1',
'max:20',
Rule::in([
'cw',
'unlisted',
'banned',
// 'popular',
'new',
'all'
])
],
]);
if($request->has('q') && $request->filled('q')) {
$instances = Instance::where('domain', 'like', '%' . $request->input('q') . '%')->simplePaginate(10);
} else if($request->has('filter') && $request->filled('filter')) {
switch ($request->filter) {
case 'cw':
$instances = Instance::select('id', 'domain', 'unlisted', 'auto_cw', 'banned')->whereAutoCw(true)->orderByDesc('id')->simplePaginate(10);
break;
case 'unlisted':
$instances = Instance::select('id', 'domain', 'unlisted', 'auto_cw', 'banned')->whereUnlisted(true)->orderByDesc('id')->simplePaginate(10);
break;
case 'banned':
$instances = Instance::select('id', 'domain', 'unlisted', 'auto_cw', 'banned')->whereBanned(true)->orderByDesc('id')->simplePaginate(10);
break;
case 'new':
$instances = Instance::select('id', 'domain', 'unlisted', 'auto_cw', 'banned')->latest()->simplePaginate(10);
break;
// case 'popular':
// $popular = Profile::selectRaw('*, count(domain) as count')
// ->whereNotNull('domain')
// ->groupBy('domain')
// ->orderByDesc('count')
// ->take(10)
// ->get()
// ->pluck('domain')
// ->toArray();
// $instances = Instance::whereIn('domain', $popular)->simplePaginate(10);
// break;
default:
$instances = Instance::select('id', 'domain', 'unlisted', 'auto_cw', 'banned')->orderByDesc('id')->simplePaginate(10);
break;
}
} else {
$instances = Instance::select('id', 'domain', 'unlisted', 'auto_cw', 'banned')->orderByDesc('id')->simplePaginate(10);
}
return view('admin.instances.home', compact('instances'));
return view('admin.instances.home');
}
public function instanceScan(Request $request)
@ -133,4 +80,146 @@ trait AdminInstanceController
return response()->json([]);
}
public function getInstancesStatsApi(Request $request)
{
return InstanceService::stats();
}
public function getInstancesQueryApi(Request $request)
{
$this->validate($request, [
'q' => 'required'
]);
$q = $request->input('q');
return AdminInstance::collection(
Instance::where('domain', 'like', '%' . $q . '%')
->orderByDesc('user_count')
->cursorPaginate(20)
->withQueryString()
);
}
public function getInstancesApi(Request $request)
{
$this->validate($request, [
'filter' => [
'nullable',
'string',
'min:1',
'max:20',
Rule::in([
'cw',
'unlisted',
'banned',
'popular_users',
'popular_statuses',
'new',
'all'
])
],
]);
$filter = $request->input('filter');
$query = $request->input('q');
return AdminInstance::collection(Instance::when($query, function($q, $qq) use($query) {
return $q->where('domain', 'like', '%' . $query . '%');
})
->when($filter, function($q, $f) use($filter) {
if($filter == 'cw') { return $q->whereAutoCw(true)->orderByDesc('id'); }
if($filter == 'unlisted') { return $q->whereUnlisted(true)->orderByDesc('id'); }
if($filter == 'banned') { return $q->whereBanned(true)->orderByDesc('id'); }
if($filter == 'new') { return $q->orderByDesc('id'); }
if($filter == 'popular_users') { return $q->orderByDesc('user_count'); }
if($filter == 'popular_statuses') { return $q->orderByDesc('status_count'); }
return $q->orderByDesc('id');
}, function($q) {
return $q->orderByDesc('id');
})
->cursorPaginate(10)
->withQueryString());
}
public function postInstanceUpdateApi(Request $request)
{
$this->validate($request, [
'id' => 'required',
'banned' => 'boolean',
'auto_cw' => 'boolean',
'unlisted' => 'boolean',
'notes' => 'nullable|string|max:500',
]);
$id = $request->input('id');
$instance = Instance::findOrFail($id);
$instance->update($request->only([
'banned',
'auto_cw',
'unlisted',
'notes'
]));
InstanceService::refresh();
return new AdminInstance($instance);
}
public function postInstanceCreateNewApi(Request $request)
{
$this->validate($request, [
'domain' => 'required|string',
'banned' => 'boolean',
'auto_cw' => 'boolean',
'unlisted' => 'boolean',
'notes' => 'nullable|string|max:500'
]);
$domain = $request->input('domain');
abort_if(!strpos($domain, '.'), 400, 'Invalid domain');
abort_if(!filter_var($domain, FILTER_VALIDATE_DOMAIN), 400, 'Invalid domain');
$instance = new Instance;
$instance->domain = $request->input('domain');
$instance->banned = $request->input('banned');
$instance->auto_cw = $request->input('auto_cw');
$instance->unlisted = $request->input('unlisted');
$instance->manually_added = true;
$instance->notes = $request->input('notes');
$instance->save();
InstanceService::refresh();
return new AdminInstance($instance);
}
public function postInstanceRefreshStatsApi(Request $request)
{
$this->validate($request, [
'id' => 'required'
]);
$instance = Instance::findOrFail($request->input('id'));
$instance->user_count = Profile::whereDomain($instance->domain)->count();
$instance->status_count = Profile::whereDomain($instance->domain)->leftJoin('statuses', 'profiles.id', '=', 'statuses.profile_id')->count();
$instance->save();
return new AdminInstance($instance);
}
public function postInstanceDeleteApi(Request $request)
{
$this->validate($request, [
'id' => 'required'
]);
$instance = Instance::findOrFail($request->input('id'));
$instance->delete();
InstanceService::refresh();
return 200;
}
}

View file

@ -24,6 +24,9 @@ class AdminInstance extends JsonResource
'user_count' => $this->user_count,
'status_count' => $this->status_count,
'last_crawled_at' => $this->last_crawled_at,
'notes' => $this->notes,
'base_domain' => $this->base_domain,
'ban_subdomains' => $this->ban_subdomains,
'actors_last_synced_at' => $this->actors_last_synced_at,
'created_at' => $this->created_at,
];

View file

@ -6,7 +6,7 @@ use Illuminate\Database\Eloquent\Model;
class Instance extends Model
{
protected $fillable = ['domain'];
protected $fillable = ['domain', 'banned', 'auto_cw', 'unlisted', 'notes'];
public function profiles()
{

View file

@ -11,6 +11,7 @@ class InstanceService
const CACHE_KEY_BANNED_DOMAINS = 'instances:banned:domains';
const CACHE_KEY_UNLISTED_DOMAINS = 'instances:unlisted:domains';
const CACHE_KEY_NSFW_DOMAINS = 'instances:auto_cw:domains';
const CACHE_KEY_STATS = 'pf:services:instances:stats';
public static function getByDomain($domain)
{
@ -52,11 +53,24 @@ class InstanceService
});
}
public static function stats()
{
return Cache::remember(self::CACHE_KEY_STATS, 86400, function() {
return [
'total_count' => Instance::count(),
'new_count' => Instance::where('created_at', '>', now()->subDays(14))->count(),
'banned_count' => Instance::whereBanned(true)->count(),
'nsfw_count' => Instance::whereAutoCw(true)->count()
];
});
}
public static function refresh()
{
Cache::forget(self::CACHE_KEY_BANNED_DOMAINS);
Cache::forget(self::CACHE_KEY_UNLISTED_DOMAINS);
Cache::forget(self::CACHE_KEY_NSFW_DOMAINS);
Cache::forget(self::CACHE_KEY_STATS);
self::getBannedDomains();
self::getUnlistedDomains();

View file

@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('instances', function (Blueprint $table) {
$table->text('notes')->nullable();
$table->boolean('manually_added')->default(false);
$table->string('base_domain')->nullable();
$table->boolean('ban_subdomains')->nullable()->index();
$table->string('ip_address')->nullable();
$table->boolean('list_limitation')->default(false)->index();
$table->index('banned');
$table->index('auto_cw');
$table->index('unlisted');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('instances', function (Blueprint $table) {
$table->dropColumn('notes');
$table->dropColumn('manually_added');
$table->dropColumn('base_domain');
$table->dropColumn('ban_subdomains');
$table->dropColumn('ip_address');
$table->dropColumn('list_limitation');
$table->dropIndex('instances_banned_index');
$table->dropIndex('instances_auto_cw_index');
$table->dropIndex('instances_unlisted_index');
});
}
};

View file

@ -0,0 +1,628 @@
<template>
<div>
<div class="header bg-primary pb-3 mt-n4">
<div class="container-fluid">
<div class="header-body">
<div class="row align-items-center py-4">
<div class="col-lg-6 col-7">
<p class="display-1 text-white d-inline-block mb-0">Instances</p>
</div>
</div>
<div class="row">
<div class="col-xl-2 col-md-6">
<div class="mb-3">
<h5 class="text-light text-uppercase mb-0">Total Instances</h5>
<span class="text-white h2 font-weight-bold mb-0 human-size">{{ prettyCount(stats.total_count) }}</span>
</div>
</div>
<div class="col-xl-2 col-md-6">
<div class="mb-3">
<h5 class="text-light text-uppercase mb-0">New (past 14 days)</h5>
<span class="text-white h2 font-weight-bold mb-0 human-size">{{ prettyCount(stats.new_count) }}</span>
</div>
</div>
<div class="col-xl-2 col-md-6">
<div class="mb-3">
<h5 class="text-light text-uppercase mb-0">Banned Instances</h5>
<span class="text-white h2 font-weight-bold mb-0 human-size">{{ prettyCount(stats.banned_count) }}</span>
</div>
</div>
<div class="col-xl-2 col-md-6">
<div class="mb-3">
<h5 class="text-light text-uppercase mb-0">NSFW Instances</h5>
<span class="text-white h2 font-weight-bold mb-0 human-size">{{ prettyCount(stats.nsfw_count) }}</span>
</div>
</div>
<div class="col-xl-2 col-md-6">
<div class="mb-3">
<button class="btn btn-outline-white btn-block btn-sm mt-1" @click.prevent="showAddModal = true">Create New Instance</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="!loaded" class="my-5 text-center">
<b-spinner />
</div>
<div v-else class="m-n2 m-lg-4">
<div class="container-fluid mt-4">
<div class="row mb-3 justify-content-between">
<div class="col-12 col-md-8">
<ul class="nav nav-pills">
<li class="nav-item">
<button :class="['nav-link', { active: tabIndex == 0}]" @click="toggleTab(0)">All</button>
</li>
<li class="nav-item">
<button :class="['nav-link', { active: tabIndex == 1}]" @click="toggleTab(1)">New</button>
</li>
<li class="nav-item">
<button :class="['nav-link', { active: tabIndex == 2}]" @click="toggleTab(2)">Banned</button>
</li>
<li class="nav-item">
<button :class="['nav-link', { active: tabIndex == 3}]" @click="toggleTab(3)">NSFW</button>
</li>
<li class="nav-item">
<button :class="['nav-link', { active: tabIndex == 4}]" @click="toggleTab(4)">Unlisted</button>
</li>
<li class="nav-item">
<button :class="['nav-link', { active: tabIndex == 5}]" @click="toggleTab(5)">Most Users</button>
</li>
<li class="nav-item">
<button :class="['nav-link', { active: tabIndex == 6}]" @click="toggleTab(6)">Most Statuses</button>
</li>
</ul>
</div>
<div class="col-12 col-md-4">
<autocomplete
:search="composeSearch"
:disabled="searchLoading"
:defaultValue="searchQuery"
placeholder="Search instances by domain"
aria-label="Search instances by domain"
:get-result-value="getTagResultValue"
@submit="onSearchResultClick"
ref="autocomplete"
>
<template #result="{ result, props }">
<li
v-bind="props"
class="autocomplete-result d-flex justify-content-between align-items-center"
>
<div class="font-weight-bold" :class="{ 'text-danger': result.banned }">
{{ result.domain }}
</div>
<div class="small text-muted">
{{ prettyCount(result.user_count) }} users
</div>
</li>
</template>
</autocomplete>
</div>
</div>
<div class="table-responsive">
<table class="table table-dark">
<thead class="thead-dark">
<tr>
<th scope="col" class="cursor-pointer" v-html="buildColumn('ID', 'id')" @click="toggleCol('id')"></th>
<th scope="col" class="cursor-pointer" v-html="buildColumn('Domain', 'name')" @click="toggleCol('name')"></th>
<th scope="col" class="cursor-pointer" v-html="buildColumn('Software', 'software')" @click="toggleCol('software')"></th>
<th scope="col" class="cursor-pointer" v-html="buildColumn('User Count', 'user_count')" @click="toggleCol('user_count')"></th>
<th scope="col" class="cursor-pointer" v-html="buildColumn('Status Count', 'status_count')" @click="toggleCol('status_count')"></th>
<th scope="col" class="cursor-pointer" v-html="buildColumn('Banned', 'banned')" @click="toggleCol('banned')"></th>
<th scope="col" class="cursor-pointer" v-html="buildColumn('NSFW', 'auto_cw')" @click="toggleCol('auto_cw')"></th>
<th scope="col" class="cursor-pointer" v-html="buildColumn('Unlisted', 'unlisted')" @click="toggleCol('unlisted')"></th>
<th scope="col">Created</th>
</tr>
</thead>
<tbody>
<tr v-for="(instance, idx) in instances">
<td class="font-weight-bold text-monospace text-muted">
<a href="#" @click.prevent="openInstanceModal(instance.id)">
{{ instance.id }}
</a>
</td>
<td class="font-weight-bold">{{ instance.domain }}</td>
<td class="font-weight-bold">{{ instance.software }}</td>
<td class="font-weight-bold">{{ prettyCount(instance.user_count) }}</td>
<td class="font-weight-bold">{{ prettyCount(instance.status_count) }}</td>
<td class="font-weight-bold" v-html="boolIcon(instance.banned, 'text-danger')"></td>
<td class="font-weight-bold" v-html="boolIcon(instance.auto_cw, 'text-danger')"></td>
<td class="font-weight-bold" v-html="boolIcon(instance.unlisted, 'text-danger')"></td>
<td class="font-weight-bold">{{ timeAgo(instance.created_at) }}</td>
</tr>
</tbody>
</table>
</div>
<div class="d-flex align-items-center justify-content-center">
<button
class="btn btn-primary rounded-pill"
:disabled="!pagination.prev"
@click="paginate('prev')">
Prev
</button>
<button
class="btn btn-primary rounded-pill"
:disabled="!pagination.next"
@click="paginate('next')">
Next
</button>
</div>
</div>
</div>
<b-modal
v-model="showInstanceModal"
title="View Instance"
header-class="d-flex align-items-center justify-content-center mb-0 pb-0"
ok-title="Save"
:ok-disabled="!editingInstanceChanges"
@ok="saveInstanceModalChanges">
<div v-if="editingInstance && canEditInstance" class="list-group">
<div class="list-group-item d-flex align-items-center justify-content-between">
<div class="text-muted small">Domain</div>
<div class="font-weight-bold">{{ editingInstance.domain }}</div>
</div>
<div class="list-group-item d-flex align-items-center justify-content-between">
<div v-if="editingInstance.software">
<div class="text-muted small">Software</div>
<div class="font-weight-bold">{{ editingInstance.software ?? 'Unknown' }}</div>
</div>
<div>
<div class="text-muted small">Total Users</div>
<div class="font-weight-bold">{{ formatCount(editingInstance.user_count ?? 0) }}</div>
</div>
<div>
<div class="text-muted small">Total Statuses</div>
<div class="font-weight-bold">{{ formatCount(editingInstance.status_count ?? 0) }}</div>
</div>
</div>
<div class="list-group-item d-flex align-items-center justify-content-between">
<div class="text-muted small">Banned</div>
<div class="mr-n2 mb-1">
<b-form-checkbox v-model="editingInstance.banned" switch size="lg"></b-form-checkbox>
</div>
</div>
<div class="list-group-item d-flex align-items-center justify-content-between">
<div class="text-muted small">Apply CW to Media</div>
<div class="mr-n2 mb-1">
<b-form-checkbox v-model="editingInstance.auto_cw" switch size="lg"></b-form-checkbox>
</div>
</div>
<div class="list-group-item d-flex align-items-center justify-content-between">
<div class="text-muted small">Unlisted</div>
<div class="mr-n2 mb-1">
<b-form-checkbox v-model="editingInstance.unlisted" switch size="lg"></b-form-checkbox>
</div>
</div>
<div class="list-group-item d-flex justify-content-between" :class="[ instanceModalNotes ? 'flex-column gap-2' : 'align-items-center']">
<div class="text-muted small">Notes</div>
<transition name="fade">
<div v-if="instanceModalNotes" class="w-100">
<b-form-textarea v-model="editingInstance.notes" rows="3" max-rows="5" maxlength="500"></b-form-textarea>
<p class="small text-muted">{{editingInstance.notes ? editingInstance.notes.length : 0}}/500</p>
</div>
<div v-else class="mb-1">
<a href="#" class="font-weight-bold small" @click.prevent="showModalNotes()">{{editingInstance.notes ? 'View' : 'Add'}}</a>
</div>
</transition>
</div>
</div>
<template #modal-footer>
<div class="w-100 d-flex justify-content-between align-items-center">
<div>
<b-button
variant="outline-danger"
size="sm"
@click="deleteInstanceModal"
>
Delete
</b-button>
<b-button
v-if="!refreshedModalStats"
variant="outline-primary"
size="sm"
@click="refreshModalStats"
>
Refresh Stats
</b-button>
</div>
<div>
<b-button
variant="secondary"
@click="showInstanceModal = false"
>
Close
</b-button>
<b-button
variant="primary"
@click="saveInstanceModalChanges"
>
Save
</b-button>
</div>
</div>
</template>
</b-modal>
<b-modal
v-model="showAddModal"
title="Add Instance"
ok-title="Save"
:ok-disabled="addNewInstance.domain.length < 2"
@ok="saveNewInstance">
<div class="list-group">
<div class="list-group-item d-flex align-items-center justify-content-between">
<div class="text-muted small">Domain</div>
<div>
<b-form-input v-model="addNewInstance.domain" placeholder="Add domain here" />
<p class="small text-light mb-0">Enter a valid domain without https://</p>
</div>
</div>
<div class="list-group-item d-flex align-items-center justify-content-between">
<div class="text-muted small">Banned</div>
<div class="mr-n2 mb-1">
<b-form-checkbox v-model="addNewInstance.banned" switch size="lg"></b-form-checkbox>
</div>
</div>
<div class="list-group-item d-flex align-items-center justify-content-between">
<div class="text-muted small">Apply CW to Media</div>
<div class="mr-n2 mb-1">
<b-form-checkbox v-model="addNewInstance.auto_cw" switch size="lg"></b-form-checkbox>
</div>
</div>
<div class="list-group-item d-flex align-items-center justify-content-between">
<div class="text-muted small">Unlisted</div>
<div class="mr-n2 mb-1">
<b-form-checkbox v-model="addNewInstance.unlisted" switch size="lg"></b-form-checkbox>
</div>
</div>
<div class="list-group-item d-flex flex-column gap-2 justify-content-between">
<div class="text-muted small">Notes</div>
<div class="w-100">
<b-form-textarea v-model="addNewInstance.notes" rows="3" max-rows="5" maxlength="500" placeholder="Add optional notes here"></b-form-textarea>
<p class="small text-muted">{{addNewInstance.notes ? addNewInstance.notes.length : 0}}/500</p>
</div>
</div>
</div>
</b-modal>
</div>
</template>
<script type="text/javascript">
import Autocomplete from '@trevoreyre/autocomplete-vue'
import '@trevoreyre/autocomplete-vue/dist/style.css'
export default {
components: {
Autocomplete,
},
data() {
return {
loaded: false,
tabIndex: 0,
stats: {
total_count: 0,
new_count: 0,
banned_count: 0,
nsfw_count: 0
},
instances: [],
pagination: [],
sortCol: undefined,
sortDir: undefined,
searchQuery: undefined,
filterMap: [
'all',
'new',
'banned',
'cw',
'unlisted',
'popular_users',
'popular_statuses'
],
searchLoading: false,
showInstanceModal: false,
instanceModal: {},
editingInstanceChanges: false,
canEditInstance: false,
editingInstance: {},
editingInstanceIndex: 0,
instanceModalNotes: false,
showAddModal: false,
refreshedModalStats: false,
addNewInstance: {
domain: "",
banned: false,
auto_cw: false,
unlisted: false,
notes: undefined
}
}
},
mounted() {
this.fetchStats();
let u = new URLSearchParams(window.location.search);
if(u.has('filter') || u.has('cursor') && !u.has('q')) {
let url = '/i/admin/api/instances/get?';
let filter = u.get('filter');
if(filter) {
this.tabIndex = this.filterMap.indexOf(filter);
url = url + 'filter=' + filter + '&';
}
let cursor = u.get('cursor');
if(cursor) {
url = url + 'cursor=' + cursor;
}
this.fetchInstances(url);
} else if(u.has('q')) {
this.tabIndex = -1;
this.searchQuery = u.get('q');
let cursor = u.has('cursor');
let q = u.get('q');
let url = '/i/admin/api/instances/query?q=' + q;
if(cursor) {
url = url + '&cursor=' + u.get('cursor');
}
this.fetchInstances(url);
} else {
this.fetchInstances();
}
},
watch: {
editingInstance: {
deep: true,
immediate: true,
handler: function(updated, old) {
if(!this.canEditInstance) {
return;
}
if(
JSON.stringify(old) === JSON.stringify(this.instances.filter(i => i.id === updated.id)[0]) &&
JSON.stringify(updated) === JSON.stringify(this.instanceModal)
) {
this.editingInstanceChanges = true;
} else {
this.editingInstanceChanges = false;
}
}
}
},
methods: {
fetchStats() {
axios.get('/i/admin/api/instances/stats')
.then(res => {
this.stats = res.data;
})
},
fetchInstances(url = '/i/admin/api/instances/get') {
axios.get(url)
.then(res => {
this.instances = res.data.data;
this.pagination = {...res.data.links, ...res.data.meta};
})
.then(() => {
this.$nextTick(() => {
this.loaded = true;
})
})
},
toggleTab(idx) {
this.loaded = false;
this.tabIndex = idx;
this.searchQuery = undefined;
let url = '/i/admin/api/instances/get?filter=' + this.filterMap[idx];
history.pushState(null, '', '/i/admin/instances?filter=' + this.filterMap[idx]);
this.fetchInstances(url);
},
prettyCount(str) {
if(str) {
return str.toLocaleString('en-CA', { compactDisplay: "short", notation: "compact"});
} else {
return 0;
}
return str;
},
formatCount(str) {
if(str) {
return str.toLocaleString('en-CA');
} else {
return 0;
}
return str;
},
timeAgo(str) {
if(!str) {
return str;
}
return App.util.format.timeAgo(str);
},
boolIcon(val, success = 'text-success', danger = 'text-muted') {
if(val) {
return `<i class="far fa-check-circle fa-lg ${success}"></i>`;
}
return `<i class="far fa-times-circle fa-lg ${danger}"></i>`;
},
toggleCol(col) {
// this.sortCol = col;
// if(!this.sortDir) {
// this.sortDir = 'desc';
// } else {
// this.sortDir = this.sortDir == 'asc' ? 'desc' : 'asc';
// }
// let url = '/i/admin/api/hashtags/query?sort=' + col + '&dir=' + this.sortDir;
// this.fetchHashtags(url);
},
buildColumn(name, col) {
let icon = `<i class="far fa-sort"></i>`;
if(col == this.sortCol) {
icon = this.sortDir == 'desc' ?
`<i class="far fa-sort-up"></i>` :
`<i class="far fa-sort-down"></i>`
}
return `${name} ${icon}`;
},
paginate(dir) {
event.currentTarget.blur();
let apiUrl = dir == 'next' ? this.pagination.next : this.pagination.prev;
let cursor = dir == 'next' ? this.pagination.next_cursor : this.pagination.prev_cursor;
let url = '/i/admin/instances?';
if(this.tabIndex && !this.searchQuery) {
url = url + 'filter=' + this.filterMap[this.tabIndex] + '&';
}
if(cursor) {
url = url + 'cursor=' + cursor;
}
if(this.searchQuery) {
url = url + '&q=' + this.searchQuery;
}
history.pushState(null, '', url);
this.fetchInstances(apiUrl);
},
composeSearch(input) {
if (input.length < 1) { return []; };
this.searchQuery = input;
history.pushState(null, '', '/i/admin/instances?q=' + input);
return axios.get('/i/admin/api/instances/query', {
params: {
q: input,
}
}).then(res => {
if(!res || !res.data) {
this.fetchInstances();
} else {
this.tabIndex = -1;
this.instances = res.data.data;
this.pagination = {...res.data.links, ...res.data.meta};
}
return res.data.data;
});
},
getTagResultValue(result) {
return result.name;
},
onSearchResultClick(result) {
this.openInstanceModal(result.id);
return;
},
openInstanceModal(id) {
const cached = this.instances.filter(i => i.id === id)[0];
this.refreshedModalStats = false;
this.editingInstanceChanges = false;
this.instanceModalNotes = false;
this.canEditInstance = false;
this.instanceModal = cached;
this.$nextTick(() => {
this.editingInstance = cached;
this.showInstanceModal = true;
this.canEditInstance = true;
})
},
showModalNotes() {
this.instanceModalNotes = true;
},
saveInstanceModalChanges() {
axios.post('/i/admin/api/instances/update', this.editingInstance)
.then(res => {
this.showInstanceModal = false;
this.$bvToast.toast(`Successfully updated ${res.data.data.domain}`, {
title: 'Instance Updated',
autoHideDelay: 5000,
appendToast: true,
variant: 'success'
})
})
},
saveNewInstance() {
axios.post('/i/admin/api/instances/create', this.addNewInstance)
.then(res => {
this.showInstanceModal = false;
this.instances.unshift(res.data.data);
})
.catch(err => {
swal('Oops!', 'An error occured, please try again later.', 'error');
this.addNewInstance = {
domain: "",
banned: false,
auto_cw: false,
unlisted: false,
notes: undefined
}
})
},
refreshModalStats() {
axios.post('/i/admin/api/instances/refresh-stats', {
id: this.instanceModal.id
})
.then(res => {
this.refreshedModalStats = true;
this.instanceModal = res.data.data;
this.editingInstance = res.data.data;
this.instances = this.instances.map(i => {
if(i.id === res.data.data.id) {
return res.data.data;
}
return i;
})
})
},
deleteInstanceModal() {
if(!window.confirm('Are you sure you want to delete this instance? This will not delete posts or profiles from this instance.')) {
return;
}
axios.post('/i/admin/api/instances/delete', {
id: this.instanceModal.id
})
.then(res => {
this.showInstanceModal = false;
this.instances = this.instances.filter(i => i.id != this.instanceModal.id);
})
}
}
}
</script>
<style lang="scss" scoped>
.gap-2 {
gap: 1rem;
}
</style>

View file

@ -26,6 +26,11 @@ Vue.component(
require('./../components/admin/AdminDirectory.vue').default
);
Vue.component(
'instances-component',
require('./../components/admin/AdminInstances.vue').default
);
Vue.component(
'hashtag-component',
require('./../components/admin/AdminHashtags.vue').default

View file

@ -1,219 +1,12 @@
@extends('admin.partial.template-full')
@section('section')
<div class="title d-flex justify-content-between align-items-center">
<h3 class="font-weight-bold mr-5">Instances</h3>
<div class="btn-group btn-group-sm">
<a class="btn btn-{{!request()->filled('filter')||request()->query('filter')=='all'?'primary':'outline-primary'}} font-weight-bold" href="?filter=all">All</a>
{{-- <a class="btn btn-{{request()->query('filter')=='popular'?'primary':'outline-primary'}} font-weight-bold" href="?filter=popular">Popular</a> --}}
<a class="btn btn-{{request()->query('filter')=='new'?'primary':'outline-primary'}} font-weight-bold" href="?filter=new">New</a>
<a class="btn btn-{{request()->query('filter')=='cw'?'primary':'outline-primary'}} font-weight-bold" href="?filter=cw">CW</a>
<a class="btn btn-{{request()->query('filter')=='banned'?'primary':'outline-primary'}} font-weight-bold" href="?filter=banned">Banned</a>
<a class="btn btn-{{request()->query('filter')=='unlisted'?'primary':'outline-primary'}} font-weight-bold" href="?filter=unlisted">Unlisted</a>
</div>
<div class="">
</div>
<form class="" method="get">
<input class="form-control rounded-pill" name="q" value="{{request()->query('q')}}" placeholder="Search domain">
</form>
</div>
<hr>
<div class="row">
<div class="col-12 col-md-8 offset-md-2">
@if($instances->count() == 0 && !request()->has('filter') && !request()->has('q'))
<div class="alert alert-warning mb-3">
<p class="lead font-weight-bold mb-0">Warning</p>
<p class="font-weight-lighter mb-0">No instances were found.</p>
</div>
<p class="font-weight-lighter">Do you want to scan and populate instances from Profiles and Statuses?</p>
<p>
<form method="post">
@csrf
<button type="submit" class="btn btn-primary py-1 font-weight-bold">Run Scan</button>
</form>
</p>
@else
<ul class="list-group">
@foreach($instances as $instance)
<li class="list-group-item">
<div>
<div class="d-flex justify-content-between align-items-center">
<p class="h4 font-weight-light mb-0 text-break mr-2">
{{$instance->domain}}
</p>
<p class="mb-0 text-right" style="min-width: 210px;">
@if($instance->unlisted)
<i class="fas fa-minus-circle text-danger" data-toggle="tooltip" title="Unlisted from timelines"></i>
@endif
@if($instance->auto_cw)
<i class="fas fa-eye-slash text-danger" data-toggle="tooltip" title="CW applied to all media"></i>
@endif
@if($instance->banned)
<i class="fas fa-shield-alt text-danger" data-toggle="tooltip" title="Instance is banned"></i>
@endif
<a class="btn btn-outline-primary btn-sm py-0 font-weight-normal ml-2" href="{{$instance->getUrl()}}">Overview</a>
<button class="btn btn-outline-secondary btn-sm py-0 font-weight-normal btn-action"
data-instance-id="{{$instance->id}}"
data-instance-domain="{{$instance->domain}}"
data-instance-unlisted="{{$instance->unlisted}}"
data-instance-autocw="{{$instance->auto_cw}}"
data-instance-banned="{{$instance->banned}}"
>Actions</button>
</p>
</div>
</div>
</li>
@endforeach
</ul>
<div class="d-flex justify-content-center mt-5 small">
{{$instances->links()}}
</div>
@endif
@if(request()->filled('q') && $instances->count() == 0)
<p class="text-center lead mb-0">No results found</p>
<p class="text-center font-weight-bold mb-0"><a href="/i/admin/instances">Go back</a></p>
@endif
@if(request()->filled('filter') && $instances->count() == 0)
<p class="text-center lead mb-0">No results found</p>
<p class="text-center font-weight-bold mb-0"><a href="/i/admin/instances">Go back</a></p>
@endif
</div>
</div>
<instances-component />
@endsection
@push('scripts')
<script type="text/javascript" src="{{mix('js/components.js')}}"></script>
<script type="text/javascript">
$(document).ready(function() {
$('.filesize').each(function(k,v) {
$(this).text(filesize(v.getAttribute('data-size')))
});
$('.btn-action').on('click', function(e) {
let id = this.getAttribute('data-instance-id');
let instanceDomain = this.getAttribute('data-instance-domain');
let text = 'Domain: ' + instanceDomain;
let unlisted = this.getAttribute('data-instance-unlisted');
let autocw = this.getAttribute('data-instance-autocw');
let banned = this.getAttribute('data-instance-banned');
swal({
title: 'Instance Actions',
text: text,
icon: 'warning',
buttons: {
unlist: {
text: unlisted == 0 ? "Unlist" : "Re-list",
className: "bg-warning",
value: "unlisted",
},
cw: {
text: autocw == 0 ? "CW Media" : "Remove AutoCW",
className: "bg-warning",
value: "autocw",
},
ban: {
text: banned == 0 ? "Ban" : "Unban",
className: "bg-danger",
value: "ban",
},
},
})
.then((value) => {
switch (value) {
case "unlisted":
swal({
title: "Are you sure?",
text: unlisted == 0 ?
"Are you sure you want to unlist " + instanceDomain + " ?" :
"Are you sure you want to remove the unlisted rule of " + instanceDomain + " ?",
icon: "warning",
buttons: true,
dangerMode: true,
})
.then((unlist) => {
if (unlist) {
axios.post('/i/admin/instances/edit/' + id, {
action: 'unlist'
}).then((res) => {
swal("Domain action was successful! The page will now refresh.", {
icon: "success",
});
setTimeout(function() {
window.location.href = window.location.href;
}, 5000);
}).catch((err) => {
swal("Something went wrong!", "Please try again later.", "error");
})
} else {
swal("Action Cancelled", "You successfully cancelled this action.", "error");
}
});
break;
case "autocw":
swal({
title: "Are you sure?",
text: autocw == 0 ?
"Are you sure you want to auto CW all media from " + instanceDomain + " ?" :
"Are you sure you want to remove the auto cw rule for " + instanceDomain + " ?",
icon: "warning",
buttons: true,
dangerMode: true,
})
.then((res) => {
if (res) {
axios.post('/i/admin/instances/edit/' + id, {
action: 'autocw'
}).then((res) => {
swal("Domain action was successful! The page will now refresh.", {
icon: "success",
});
setTimeout(function() {
window.location.href = window.location.href;
}, 5000);
}).catch((err) => {
swal("Something went wrong!", "Please try again later.", "error");
})
} else {
swal("Action Cancelled", "You successfully cancelled this action.", "error");
}
});
break;
case "ban":
swal({
title: "Are you sure?",
text: autocw == 0 ?
"Are you sure you want to ban " + instanceDomain + " ?" :
"Are you sure you want unban " + instanceDomain + " ?",
icon: "warning",
buttons: true,
dangerMode: true,
})
.then((res) => {
if (res) {
axios.post('/i/admin/instances/edit/' + id, {
action: 'ban'
}).then((res) => {
swal("Domain action was successful! The page will now refresh.", {
icon: "success",
});
setTimeout(function() {
window.location.href = window.location.href;
}, 5000);
}).catch((err) => {
swal("Something went wrong!", "Please try again later.", "error");
})
} else {
swal("Action Cancelled", "You successfully cancelled this action.", "error");
}
});
break;
}
});
})
});
new Vue({ el: '#panel'});
</script>
@endpush

View file

@ -113,6 +113,13 @@ Route::domain(config('pixelfed.domain.admin'))->prefix('i/admin')->group(functio
Route::get('hashtags/get', 'AdminController@hashtagsGet');
Route::post('hashtags/update', 'AdminController@hashtagsUpdate');
Route::post('hashtags/clear-trending-cache', 'AdminController@hashtagsClearTrendingCache');
Route::get('instances/get', 'AdminController@getInstancesApi');
Route::get('instances/stats', 'AdminController@getInstancesStatsApi');
Route::get('instances/query', 'AdminController@getInstancesQueryApi');
Route::post('instances/update', 'AdminController@postInstanceUpdateApi');
Route::post('instances/create', 'AdminController@postInstanceCreateNewApi');
Route::post('instances/delete', 'AdminController@postInstanceDeleteApi');
Route::post('instances/refresh-stats', 'AdminController@postInstanceRefreshStatsApi');
});
});