Browse Source

Update admin instances dashboard

Daniel Supernault 2 years ago
parent
commit
ecfc0766f8

+ 144 - 55
app/Http/Controllers/Admin/AdminInstanceController.php

@@ -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;
+	}
 }

+ 3 - 0
app/Http/Resources/AdminInstance.php

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

+ 1 - 1
app/Instance.php

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

+ 14 - 0
app/Services/InstanceService.php

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

+ 48 - 0
database/migrations/2023_03_19_050342_add_notes_to_instances_table.php

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

+ 628 - 0
resources/assets/components/admin/AdminInstances.vue

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

+ 5 - 0
resources/assets/js/admin.js

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

+ 2 - 209
resources/views/admin/instances/home.blade.php

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

+ 7 - 0
routes/web.php

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