From 11349561d697b11df7bcaa3d57ed3498eb4dd3c5 Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Tue, 20 Apr 2021 23:22:59 +0900
Subject: Use FontAwesome as web font instead of vue component (#7469)

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* Update yarn.lock

* wip

* wip
---
 src/client/components/instance-stats.vue | 14 ++++++--------
 1 file changed, 6 insertions(+), 8 deletions(-)

(limited to 'src/client/components/instance-stats.vue')

diff --git a/src/client/components/instance-stats.vue b/src/client/components/instance-stats.vue
index 75abefc2fe..aa01f1c806 100644
--- a/src/client/components/instance-stats.vue
+++ b/src/client/components/instance-stats.vue
@@ -3,7 +3,7 @@
 	<div class="stats" v-if="info">
 		<div class="_panel">
 			<div>
-				<b><Fa :icon="faUser"/>{{ $ts.users }}</b>
+				<b><i class="fas fa-user"></i>{{ $ts.users }}</b>
 				<small>{{ $ts.local }}</small>
 			</div>
 			<div>
@@ -23,7 +23,7 @@
 		</div>
 		<div class="_panel">
 			<div>
-				<b><Fa :icon="faUser"/>{{ $ts.users }}</b>
+				<b><i class="fas fa-user"></i>{{ $ts.users }}</b>
 				<small>{{ $ts.remote }}</small>
 			</div>
 			<div>
@@ -43,7 +43,7 @@
 		</div>
 		<div class="_panel">
 			<div>
-				<b><Fa :icon="faPencilAlt"/>{{ $ts.notes }}</b>
+				<b><i class="fas fa-pencil-alt"></i>{{ $ts.notes }}</b>
 				<small>{{ $ts.local }}</small>
 			</div>
 			<div>
@@ -63,7 +63,7 @@
 		</div>
 		<div class="_panel">
 			<div>
-				<b><Fa :icon="faPencilAlt"/>{{ $ts.notes }}</b>
+				<b><i class="fas fa-pencil-alt"></i>{{ $ts.notes }}</b>
 				<small>{{ $ts.remote }}</small>
 			</div>
 			<div>
@@ -84,7 +84,7 @@
 	</div>
 
 	<section class="_card">
-		<div class="_title" style="position: relative;"><Fa :icon="faChartBar"/> {{ $ts.statistics }}<button @click="fetchChart" class="_button" style="position: absolute; right: 0; bottom: 0; top: 0; padding: inherit;"><Fa :icon="faSync"/></button></div>
+		<div class="_title" style="position: relative;"><i class="fas fa-chart-bar"></i> {{ $ts.statistics }}<button @click="fetchChart" class="_button" style="position: absolute; right: 0; bottom: 0; top: 0; padding: inherit;"><i class="fas fa-sync"></i></button></div>
 		<div class="_content" style="margin-top: -8px;">
 			<div class="selects" style="display: flex;">
 				<MkSelect v-model:value="chartSrc" style="margin: 0; flex: 1;">
@@ -123,7 +123,6 @@
 
 <script lang="ts">
 import { defineComponent, markRaw } from 'vue';
-import { faChartBar, faUser, faPencilAlt, faSync } from '@fortawesome/free-solid-svg-icons';
 import Chart from 'chart.js';
 import MkSelect from './ui/select.vue';
 import number from '@client/filters/number';
@@ -173,7 +172,6 @@ export default defineComponent({
 			chartInstance: null,
 			chartSrc: 'notes',
 			chartSpan: 'hour',
-			faChartBar, faUser, faPencilAlt, faSync
 		}
 	},
 
@@ -666,7 +664,7 @@ export default defineComponent({
 					> b {
 						display: block;
 
-						> [data-icon] {
+						> i {
 							width: 16px;
 							margin-right: 8px;
 						}
-- 
cgit v1.2.3-freya


From 246693b8484b72048cb515b76aa5f094f5fdeb56 Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Thu, 22 Apr 2021 22:29:33 +0900
Subject: インスタンス管理画面作り直し (#7473)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* wip

* wip

* wip

* wip
---
 locales/ja-JP.yml                                  |  13 +-
 package.json                                       |   2 +-
 src/client/components/captcha.vue                  |   4 +-
 src/client/components/form/base.vue                |   2 +
 src/client/components/form/form.scss               |   4 +-
 src/client/components/form/key-value-view.vue      |   2 +-
 src/client/components/form/object-view.vue         |   2 +-
 src/client/components/form/radios.vue              |   3 +
 src/client/components/form/suspense.vue            |   9 +-
 src/client/components/instance-stats.vue           | 252 ++-------
 src/client/components/signup.vue                   |   2 +-
 src/client/components/tab.vue                      |   1 +
 src/client/components/ui/pagination.vue            |  34 +-
 src/client/pages/instance/abuses.vue               |  12 +-
 src/client/pages/instance/announcements.vue        |  52 +-
 src/client/pages/instance/bot-protection.vue       | 138 +++++
 src/client/pages/instance/database.vue             |  60 ++
 src/client/pages/instance/email-settings.vue       | 127 +++++
 src/client/pages/instance/emojis.vue               | 215 +++----
 src/client/pages/instance/federation.vue           | 113 ++--
 src/client/pages/instance/file-dialog.vue          |   4 +-
 src/client/pages/instance/files-settings.vue       |  92 +++
 src/client/pages/instance/files.vue                |   8 +
 src/client/pages/instance/index.metrics.vue        | 574 -------------------
 src/client/pages/instance/index.vue                | 356 +++++++-----
 src/client/pages/instance/instance-block.vue       |  71 +++
 src/client/pages/instance/integrations-discord.vue |  85 +++
 src/client/pages/instance/integrations-github.vue  |  85 +++
 src/client/pages/instance/integrations-twitter.vue |  85 +++
 src/client/pages/instance/integrations.vue         |  73 +++
 src/client/pages/instance/metrics.vue              | 455 +++++++++++++++
 src/client/pages/instance/object-storage.vue       | 154 +++++
 src/client/pages/instance/other-settings.vue       |  68 +++
 src/client/pages/instance/overview.vue             | 131 +++++
 src/client/pages/instance/proxy-account.vue        |  86 +++
 src/client/pages/instance/queue.chart.vue          |  64 ++-
 src/client/pages/instance/queue.vue                |  24 +-
 src/client/pages/instance/relays.vue               |  50 +-
 src/client/pages/instance/security.vue             |  77 +++
 src/client/pages/instance/service-worker.vue       |  84 +++
 src/client/pages/instance/settings.vue             | 625 +++------------------
 src/client/pages/instance/user-dialog.vue          | 230 --------
 src/client/pages/instance/user.vue                 | 229 ++++++++
 src/client/pages/instance/users.vue                | 257 ++++-----
 src/client/pages/user-info.vue                     |  70 +--
 src/client/pages/user/index.vue                    |   2 +-
 src/client/router.ts                               |  12 +-
 src/client/scripts/get-user-menu.ts                |   8 +-
 src/client/scripts/lookup-user.ts                  |  37 ++
 src/client/ui/_common_/sidebar.vue                 |  63 +--
 src/client/ui/default.sidebar.vue                  |  63 +--
 51 files changed, 2985 insertions(+), 2284 deletions(-)
 create mode 100644 src/client/pages/instance/bot-protection.vue
 create mode 100644 src/client/pages/instance/database.vue
 create mode 100644 src/client/pages/instance/email-settings.vue
 create mode 100644 src/client/pages/instance/files-settings.vue
 delete mode 100644 src/client/pages/instance/index.metrics.vue
 create mode 100644 src/client/pages/instance/instance-block.vue
 create mode 100644 src/client/pages/instance/integrations-discord.vue
 create mode 100644 src/client/pages/instance/integrations-github.vue
 create mode 100644 src/client/pages/instance/integrations-twitter.vue
 create mode 100644 src/client/pages/instance/integrations.vue
 create mode 100644 src/client/pages/instance/metrics.vue
 create mode 100644 src/client/pages/instance/object-storage.vue
 create mode 100644 src/client/pages/instance/other-settings.vue
 create mode 100644 src/client/pages/instance/overview.vue
 create mode 100644 src/client/pages/instance/proxy-account.vue
 create mode 100644 src/client/pages/instance/security.vue
 create mode 100644 src/client/pages/instance/service-worker.vue
 delete mode 100644 src/client/pages/instance/user-dialog.vue
 create mode 100644 src/client/pages/instance/user.vue
 create mode 100644 src/client/scripts/lookup-user.ts

(limited to 'src/client/components/instance-stats.vue')

diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 989edcda2d..b557f86436 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -183,7 +183,7 @@ clearQueueConfirmTitle: "キューをクリアしますか？"
 clearQueueConfirmText: "未配達の投稿は配送されなくなります。通常この操作を行う必要はありません。"
 clearCachedFiles: "キャッシュをクリア"
 clearCachedFilesConfirm: "キャッシュされたリモートファイルをすべて削除しますか？"
-blockedInstances: "インスタンスブロック"
+blockedInstances: "ブロックしたインスタンス"
 blockedInstancesDescription: "ブロックしたいインスタンスのホストを改行で区切って設定します。ブロックされたインスタンスは、このインスタンスとやり取りできなくなります。"
 muteAndBlock: "ミュートとブロック"
 mutedUsers: "ミュートしたユーザー"
@@ -349,7 +349,6 @@ antennaExcludeKeywords: "除外キーワード"
 antennaKeywordsDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります"
 notifyAntenna: "新しいノートを通知する"
 withFileAntenna: "ファイルが添付されたノートのみ"
-serviceworker: "ServiceWorker"
 enableServiceworker: "ServiceWorkerを有効にする"
 antennaUsersDescription: "ユーザー名を改行で区切って指定します"
 caseSensitive: "大文字小文字を区別する"
@@ -568,7 +567,7 @@ pluginTokenRequestedDescription: "このプラグインはここで設定した
 notificationType: "通知の種類"
 edit: "編集"
 useStarForReactionFallback: "リアクション絵文字が不明な場合、代わりに★を使う"
-emailConfig: "メールサーバー設定"
+emailServer: "メールサーバー"
 enableEmail: "メール配信機能を有効化する"
 emailConfigInfo: "メールアドレスの確認やパスワードリセットの際に使います"
 email: "メール"
@@ -728,6 +727,14 @@ hideOnlineStatusDescription: "オンライン状態を隠すと、検索など
 online: "オンライン"
 active: "アクティブ"
 offline: "オフライン"
+notRecommended: "非推奨"
+botProtection: "Bot防御"
+instanceBlocking: "インスタンスブロック"
+selectAccount: "アカウントを選択"
+enabled: "有効"
+disabled: "無効"
+quickAction: "クイックアクション"
+user: "ユーザー"
 
 _email:
   _follow:
diff --git a/package.json b/package.json
index 26aacae492..c6fd1a0227 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
 	"name": "misskey",
 	"author": "syuilo <syuilotan@yahoo.co.jp>",
-	"version": "12.78.0-beta.2",
+	"version": "12.78.0-beta.3",
 	"codename": "indigo",
 	"repository": {
 		"type": "git",
diff --git a/src/client/components/captcha.vue b/src/client/components/captcha.vue
index 710fcd6169..26215df09d 100644
--- a/src/client/components/captcha.vue
+++ b/src/client/components/captcha.vue
@@ -18,7 +18,7 @@ type Captcha = {
 	getResponse(id: string): string;
 };
 
-type CaptchaProvider = 'hcaptcha' | 'grecaptcha';
+type CaptchaProvider = 'hcaptcha' | 'recaptcha';
 
 type CaptchaContainer = {
 	readonly [_ in CaptchaProvider]?: Captcha;
@@ -57,7 +57,7 @@ export default defineComponent({
 		src() {
 			const endpoint = ({
 				hcaptcha: 'https://hcaptcha.com/1',
-				grecaptcha: 'https://www.recaptcha.net/recaptcha',
+				recaptcha: 'https://www.recaptcha.net/recaptcha',
 			} as Record<PropertyKey, unknown>)[this.provider];
 
 			return `${typeof endpoint == 'string' ? endpoint : 'about:invalid'}/api.js?render=explicit`;
diff --git a/src/client/components/form/base.vue b/src/client/components/form/base.vue
index 34deb39465..132942d527 100644
--- a/src/client/components/form/base.vue
+++ b/src/client/components/form/base.vue
@@ -24,6 +24,8 @@ export default defineComponent({
 	--formXPadding: 32px;
 	--formYPadding: 32px;
 
+	--formContentHMargin: 16px;
+
 	font-size: 95%;
 	line-height: 1.3em;
 	background: var(--bg);
diff --git a/src/client/components/form/form.scss b/src/client/components/form/form.scss
index 8c01fad727..05994ae650 100644
--- a/src/client/components/form/form.scss
+++ b/src/client/components/form/form.scss
@@ -30,7 +30,7 @@
 	top: var(--stickyTop, 0px);
 	z-index: 2;
 	margin: -8px calc(var(--formXPadding) * -1) 0 calc(var(--formXPadding) * -1);
-	padding: 8px calc(16px + var(--formXPadding)) 8px calc(16px + var(--formXPadding));
+	padding: 8px calc(var(--formContentHMargin) + var(--formXPadding)) 8px calc(var(--formContentHMargin) + var(--formXPadding));
 	background: var(--X17);
 	-webkit-backdrop-filter: blur(10px);
 	backdrop-filter: blur(10px);
@@ -42,7 +42,7 @@
 }
 
 ._formCaption {
-	padding: 8px 16px 0 16px;
+	padding: 8px var(--formContentHMargin) 0 var(--formContentHMargin);
 }
 
 ._formItem {
diff --git a/src/client/components/form/key-value-view.vue b/src/client/components/form/key-value-view.vue
index 85f4febef9..ca4c09867f 100644
--- a/src/client/components/form/key-value-view.vue
+++ b/src/client/components/form/key-value-view.vue
@@ -20,7 +20,7 @@ export default defineComponent({
 .anocepby {
 	display: flex;
 	align-items: center;
-	padding: 14px 16px;
+	padding: 14px var(--formContentHMargin);
 
 	> .key {
 		margin-right: 12px;
diff --git a/src/client/components/form/object-view.vue b/src/client/components/form/object-view.vue
index cbd4186e56..59fb62b5e6 100644
--- a/src/client/components/form/object-view.vue
+++ b/src/client/components/form/object-view.vue
@@ -75,7 +75,7 @@ export default defineComponent({
 			max-width: 100%;
 			min-height: 130px;
 			margin: 0;
-			padding: 16px;
+			padding: 16px var(--formContentHMargin);
 			box-sizing: border-box;
 			font: inherit;
 			font-weight: normal;
diff --git a/src/client/components/form/radios.vue b/src/client/components/form/radios.vue
index 3daa7e5bbd..4cfb7c247e 100644
--- a/src/client/components/form/radios.vue
+++ b/src/client/components/form/radios.vue
@@ -18,6 +18,9 @@ export default defineComponent({
 		}
 	},
 	watch: {
+		modelValue() {
+			this.value = this.modelValue;
+		},
 		value() {
 			this.$emit('update:modelValue', this.value);
 		}
diff --git a/src/client/components/form/suspense.vue b/src/client/components/form/suspense.vue
index 6a8282733f..2a48faccb3 100644
--- a/src/client/components/form/suspense.vue
+++ b/src/client/components/form/suspense.vue
@@ -5,9 +5,9 @@
 			<MkLoading/>
 		</div>
 	</div>
-	<FormGroup v-else-if="resolved" class="_formItem">
+	<div v-else-if="resolved" class="_formItem">
 		<slot :result="result"></slot>
-	</FormGroup>
+	</div>
 	<div class="_formItem" v-else>
 		<div class="_formPanel">
 			error!
@@ -20,13 +20,8 @@
 <script lang="ts">
 import { defineComponent, PropType, ref, watch } from 'vue';
 import './form.scss';
-import FormGroup from './group.vue';
 
 export default defineComponent({
-	components: {
-		FormGroup,
-	},
-
 	props: {
 		p: {
 			type: Function as PropType<() => Promise<any>>,
diff --git a/src/client/components/instance-stats.vue b/src/client/components/instance-stats.vue
index aa01f1c806..432c9a1bb9 100644
--- a/src/client/components/instance-stats.vue
+++ b/src/client/components/instance-stats.vue
@@ -1,123 +1,35 @@
 <template>
-<div class="zbcjwnqg" v-size="{ max: [550, 1000] }">
-	<div class="stats" v-if="info">
-		<div class="_panel">
-			<div>
-				<b><i class="fas fa-user"></i>{{ $ts.users }}</b>
-				<small>{{ $ts.local }}</small>
-			</div>
-			<div>
-				<dl class="total">
-					<dt>{{ $ts.total }}</dt>
-					<dd>{{ number(info.originalUsersCount) }}</dd>
-				</dl>
-				<dl class="diff" :class="{ inc: usersLocalDoD > 0 }">
-					<dt>{{ $ts.dayOverDayChanges }}</dt>
-					<dd>{{ number(usersLocalDoD) }}</dd>
-				</dl>
-				<dl class="diff" :class="{ inc: usersLocalWoW > 0 }">
-					<dt>{{ $ts.weekOverWeekChanges }}</dt>
-					<dd>{{ number(usersLocalWoW) }}</dd>
-				</dl>
-			</div>
-		</div>
-		<div class="_panel">
-			<div>
-				<b><i class="fas fa-user"></i>{{ $ts.users }}</b>
-				<small>{{ $ts.remote }}</small>
-			</div>
-			<div>
-				<dl class="total">
-					<dt>{{ $ts.total }}</dt>
-					<dd>{{ number((info.usersCount - info.originalUsersCount)) }}</dd>
-				</dl>
-				<dl class="diff" :class="{ inc: usersRemoteDoD > 0 }">
-					<dt>{{ $ts.dayOverDayChanges }}</dt>
-					<dd>{{ number(usersRemoteDoD) }}</dd>
-				</dl>
-				<dl class="diff" :class="{ inc: usersRemoteWoW > 0 }">
-					<dt>{{ $ts.weekOverWeekChanges }}</dt>
-					<dd>{{ number(usersRemoteWoW) }}</dd>
-				</dl>
-			</div>
-		</div>
-		<div class="_panel">
-			<div>
-				<b><i class="fas fa-pencil-alt"></i>{{ $ts.notes }}</b>
-				<small>{{ $ts.local }}</small>
-			</div>
-			<div>
-				<dl class="total">
-					<dt>{{ $ts.total }}</dt>
-					<dd>{{ number(info.originalNotesCount) }}</dd>
-				</dl>
-				<dl class="diff" :class="{ inc: notesLocalDoD > 0 }">
-					<dt>{{ $ts.dayOverDayChanges }}</dt>
-					<dd>{{ number(notesLocalDoD) }}</dd>
-				</dl>
-				<dl class="diff" :class="{ inc: notesLocalWoW > 0 }">
-					<dt>{{ $ts.weekOverWeekChanges }}</dt>
-					<dd>{{ number(notesLocalWoW) }}</dd>
-				</dl>
-			</div>
-		</div>
-		<div class="_panel">
-			<div>
-				<b><i class="fas fa-pencil-alt"></i>{{ $ts.notes }}</b>
-				<small>{{ $ts.remote }}</small>
-			</div>
-			<div>
-				<dl class="total">
-					<dt>{{ $ts.total }}</dt>
-					<dd>{{ number((info.notesCount - info.originalNotesCount)) }}</dd>
-				</dl>
-				<dl class="diff" :class="{ inc: notesRemoteDoD > 0 }">
-					<dt>{{ $ts.dayOverDayChanges }}</dt>
-					<dd>{{ number(notesRemoteDoD) }}</dd>
-				</dl>
-				<dl class="diff" :class="{ inc: notesRemoteWoW > 0 }">
-					<dt>{{ $ts.weekOverWeekChanges }}</dt>
-					<dd>{{ number(notesRemoteWoW) }}</dd>
-				</dl>
-			</div>
-		</div>
+<div class="zbcjwnqg" style="margin-top: -8px;">
+	<div class="selects" style="display: flex;">
+		<MkSelect v-model:value="chartSrc" style="margin: 0; flex: 1;">
+			<optgroup :label="$ts.federation">
+				<option value="federation-instances">{{ $ts._charts.federationInstancesIncDec }}</option>
+				<option value="federation-instances-total">{{ $ts._charts.federationInstancesTotal }}</option>
+			</optgroup>
+			<optgroup :label="$ts.users">
+				<option value="users">{{ $ts._charts.usersIncDec }}</option>
+				<option value="users-total">{{ $ts._charts.usersTotal }}</option>
+				<option value="active-users">{{ $ts._charts.activeUsers }}</option>
+			</optgroup>
+			<optgroup :label="$ts.notes">
+				<option value="notes">{{ $ts._charts.notesIncDec }}</option>
+				<option value="local-notes">{{ $ts._charts.localNotesIncDec }}</option>
+				<option value="remote-notes">{{ $ts._charts.remoteNotesIncDec }}</option>
+				<option value="notes-total">{{ $ts._charts.notesTotal }}</option>
+			</optgroup>
+			<optgroup :label="$ts.drive">
+				<option value="drive-files">{{ $ts._charts.filesIncDec }}</option>
+				<option value="drive-files-total">{{ $ts._charts.filesTotal }}</option>
+				<option value="drive">{{ $ts._charts.storageUsageIncDec }}</option>
+				<option value="drive-total">{{ $ts._charts.storageUsageTotal }}</option>
+			</optgroup>
+		</MkSelect>
+		<MkSelect v-model:value="chartSpan" style="margin: 0;">
+			<option value="hour">{{ $ts.perHour }}</option>
+			<option value="day">{{ $ts.perDay }}</option>
+		</MkSelect>
 	</div>
-
-	<section class="_card">
-		<div class="_title" style="position: relative;"><i class="fas fa-chart-bar"></i> {{ $ts.statistics }}<button @click="fetchChart" class="_button" style="position: absolute; right: 0; bottom: 0; top: 0; padding: inherit;"><i class="fas fa-sync"></i></button></div>
-		<div class="_content" style="margin-top: -8px;">
-			<div class="selects" style="display: flex;">
-				<MkSelect v-model:value="chartSrc" style="margin: 0; flex: 1;">
-					<optgroup :label="$ts.federation">
-						<option value="federation-instances">{{ $ts._charts.federationInstancesIncDec }}</option>
-						<option value="federation-instances-total">{{ $ts._charts.federationInstancesTotal }}</option>
-					</optgroup>
-					<optgroup :label="$ts.users">
-						<option value="users">{{ $ts._charts.usersIncDec }}</option>
-						<option value="users-total">{{ $ts._charts.usersTotal }}</option>
-						<option value="active-users">{{ $ts._charts.activeUsers }}</option>
-					</optgroup>
-					<optgroup :label="$ts.notes">
-						<option value="notes">{{ $ts._charts.notesIncDec }}</option>
-						<option value="local-notes">{{ $ts._charts.localNotesIncDec }}</option>
-						<option value="remote-notes">{{ $ts._charts.remoteNotesIncDec }}</option>
-						<option value="notes-total">{{ $ts._charts.notesTotal }}</option>
-					</optgroup>
-					<optgroup :label="$ts.drive">
-						<option value="drive-files">{{ $ts._charts.filesIncDec }}</option>
-						<option value="drive-files-total">{{ $ts._charts.filesTotal }}</option>
-						<option value="drive">{{ $ts._charts.storageUsageIncDec }}</option>
-						<option value="drive-total">{{ $ts._charts.storageUsageTotal }}</option>
-					</optgroup>
-				</MkSelect>
-				<MkSelect v-model:value="chartSpan" style="margin: 0;">
-					<option value="hour">{{ $ts.perHour }}</option>
-					<option value="day">{{ $ts.perDay }}</option>
-				</MkSelect>
-			</div>
-			<canvas ref="chart"></canvas>
-		</div>
-	</section>
+	<canvas ref="chart"></canvas>
 </div>
 </template>
 
@@ -158,7 +70,6 @@ export default defineComponent({
 
 	data() {
 		return {
-			info: null,
 			notesLocalWoW: 0,
 			notesLocalDoD: 0,
 			notesRemoteWoW: 0,
@@ -216,8 +127,6 @@ export default defineComponent({
 	},
 
 	async created() {
-		this.info = await os.api('stats');
-
 		this.now = new Date();
 
 		this.fetchChart();
@@ -256,15 +165,6 @@ export default defineComponent({
 				}
 			};
 
-			this.notesLocalWoW = this.info.originalNotesCount - chart.perDay.notes.local.total[7];
-			this.notesLocalDoD = this.info.originalNotesCount - chart.perDay.notes.local.total[1];
-			this.notesRemoteWoW = (this.info.notesCount - this.info.originalNotesCount) - chart.perDay.notes.remote.total[7];
-			this.notesRemoteDoD = (this.info.notesCount - this.info.originalNotesCount) - chart.perDay.notes.remote.total[1];
-			this.usersLocalWoW = this.info.originalUsersCount - chart.perDay.users.local.total[7];
-			this.usersLocalDoD = this.info.originalUsersCount - chart.perDay.users.local.total[1];
-			this.usersRemoteWoW = (this.info.usersCount - this.info.originalUsersCount) - chart.perDay.users.remote.total[7];
-			this.usersRemoteDoD = (this.info.usersCount - this.info.originalUsersCount) - chart.perDay.users.remote.total[1];
-
 			this.chart = chart;
 
 			this.renderChart();
@@ -300,10 +200,10 @@ export default defineComponent({
 					aspectRatio: 2.5,
 					layout: {
 						padding: {
-							left: 0,
-							right: 0,
+							left: 16,
+							right: 16,
 							top: 16,
-							bottom: 0
+							bottom: 8
 						}
 					},
 					legend: {
@@ -630,90 +530,8 @@ export default defineComponent({
 
 <style lang="scss" scoped>
 .zbcjwnqg {
-	&.max-width_1000px {
-		> .stats {
-			grid-template-columns: 1fr 1fr;
-			grid-template-rows: 1fr 1fr;
-		}
-	}
-
-	&.max-width_550px {
-		> .stats {
-			grid-template-columns: 1fr;
-			grid-template-rows: 1fr 1fr 1fr 1fr;
-		}
-	}
-
-	> .stats {
-		display: grid;
-		grid-template-columns: 1fr 1fr 1fr 1fr;
-		grid-template-rows: 1fr;
-		gap: var(--margin);
-		margin-bottom: var(--margin);
-		font-size: 90%;
-
-		> div {
-			display: flex;
-			box-sizing: border-box;
-			padding: 16px 20px;
-
-			> div {
-				width: 50%;
-
-				&:first-child {
-					> b {
-						display: block;
-
-						> i {
-							width: 16px;
-							margin-right: 8px;
-						}
-					}
-
-					> small {
-						margin-left: 16px + 8px;
-						opacity: 0.7;
-					}
-				}
-
-				&:last-child {
-					> dl {
-						display: flex;
-						margin: 0;
-						line-height: 1.5em;
-
-						> dt,
-						> dd {
-							width: 50%;
-							margin: 0;
-						}
-
-						> dd {
-							text-overflow: ellipsis;
-							overflow: hidden;
-							white-space: nowrap;
-						}
-
-						&.total {
-							> dt,
-							> dd {
-								font-weight: bold;
-							}
-						}
-
-						&.diff.inc {
-							> dd {
-								color: #82c11c;
-
-								&:before {
-									content: "+";
-								}
-							}
-						}
-					}
-				}
-			}
-		}
+	> .selects {
+		padding: 8px 16px 0 16px;
 	}
 }
 </style>
diff --git a/src/client/components/signup.vue b/src/client/components/signup.vue
index 7b40561adf..671642b291 100644
--- a/src/client/components/signup.vue
+++ b/src/client/components/signup.vue
@@ -45,7 +45,7 @@
 			</I18n>
 		</label>
 		<captcha v-if="meta.enableHcaptcha" class="captcha" provider="hcaptcha" ref="hcaptcha" v-model:value="hCaptchaResponse" :sitekey="meta.hcaptchaSiteKey"/>
-		<captcha v-if="meta.enableRecaptcha" class="captcha" provider="grecaptcha" ref="recaptcha" v-model:value="reCaptchaResponse" :sitekey="meta.recaptchaSiteKey"/>
+		<captcha v-if="meta.enableRecaptcha" class="captcha" provider="recaptcha" ref="recaptcha" v-model:value="reCaptchaResponse" :sitekey="meta.recaptchaSiteKey"/>
 		<MkButton type="submit" :disabled="shouldDisableSubmitting" primary>{{ $ts.start }}</MkButton>
 	</template>
 </form>
diff --git a/src/client/components/tab.vue b/src/client/components/tab.vue
index aca4d32a22..96cbe50fb1 100644
--- a/src/client/components/tab.vue
+++ b/src/client/components/tab.vue
@@ -29,6 +29,7 @@ export default defineComponent({
 <style lang="scss">
 .pxhvhrfw {
 	display: flex;
+	font-size: 90%;
 
 	> button {
 		flex: 1;
diff --git a/src/client/components/ui/pagination.vue b/src/client/components/ui/pagination.vue
index 13181d39e2..ac8ed01e12 100644
--- a/src/client/components/ui/pagination.vue
+++ b/src/client/components/ui/pagination.vue
@@ -1,16 +1,23 @@
 <template>
-<div class="cxiknjgy">
-	<slot :items="items"></slot>
-	<div class="empty" v-if="empty" key="_empty_">
+<transition name="fade" mode="out-in">
+	<MkLoading v-if="fetching"/>
+
+	<MkError v-else-if="error" @retry="init()"/>
+
+	<div class="empty" v-else-if="empty" key="_empty_">
 		<slot name="empty"></slot>
 	</div>
-	<div class="more" v-show="more" key="_more_">
-		<MkButton class="button" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary>
-			<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
-			<template v-if="moreFetching"><MkLoading inline/></template>
-		</MkButton>
+
+	<div v-else class="cxiknjgy">
+		<slot :items="items"></slot>
+		<div class="more" v-show="more" key="_more_">
+			<MkButton class="button" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary>
+				<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
+				<template v-if="moreFetching"><MkLoading inline/></template>
+			</MkButton>
+		</div>
 	</div>
-</div>
+</transition>
 </template>
 
 <script lang="ts">
@@ -36,6 +43,15 @@ export default defineComponent({
 </script>
 
 <style lang="scss" scoped>
+.fade-enter-active,
+.fade-leave-active {
+	transition: opacity 0.125s ease;
+}
+.fade-enter-from,
+.fade-leave-to {
+	opacity: 0;
+}
+
 .cxiknjgy {
 	> .more > .button {
 		margin-left: auto;
diff --git a/src/client/pages/instance/abuses.vue b/src/client/pages/instance/abuses.vue
index 7666bc1a44..73196027dc 100644
--- a/src/client/pages/instance/abuses.vue
+++ b/src/client/pages/instance/abuses.vue
@@ -1,5 +1,5 @@
 <template>
-<div class="">
+<div class="lcixvhis">
 	<div class="_section reports">
 		<div class="_content">
 			<div class="inputs" style="display: flex;">
@@ -80,6 +80,8 @@ export default defineComponent({
 		MkPagination,
 	},
 
+	emits: ['info'],
+
 	data() {
 		return {
 			[symbols.PAGE_INFO]: {
@@ -117,6 +119,10 @@ export default defineComponent({
 		},
 	},
 
+	mounted() {
+		this.$emit('info', this[symbols.PAGE_INFO]);
+	},
+
 	methods: {
 		acct,
 
@@ -132,6 +138,10 @@ export default defineComponent({
 </script>
 
 <style lang="scss" scoped>
+.lcixvhis {
+	margin: var(--margin);
+}
+
 .bcekxzvu {
 	> .target {
 		display: flex;
diff --git a/src/client/pages/instance/announcements.vue b/src/client/pages/instance/announcements.vue
index 6a00476f9f..ac0e9d5135 100644
--- a/src/client/pages/instance/announcements.vue
+++ b/src/client/pages/instance/announcements.vue
@@ -1,28 +1,24 @@
 <template>
 <div class="ztgjmzrw">
-	<div class="_section">
-		<div class="_content">
-			<MkButton @click="add()" primary style="margin: 0 auto 16px auto;"><i class="fas fa-plus"></i> {{ $ts.add }}</MkButton>
-			<section class="_card _gap announcements" v-for="announcement in announcements">
-				<div class="_content announcement">
-					<MkInput v-model:value="announcement.title">
-						<span>{{ $ts.title }}</span>
-					</MkInput>
-					<MkTextarea v-model:value="announcement.text">
-						<span>{{ $ts.text }}</span>
-					</MkTextarea>
-					<MkInput v-model:value="announcement.imageUrl">
-						<span>{{ $ts.imageUrl }}</span>
-					</MkInput>
-					<p v-if="announcement.reads">{{ $t('nUsersRead', { n: announcement.reads }) }}</p>
-					<div class="buttons">
-						<MkButton class="button" inline @click="save(announcement)" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
-						<MkButton class="button" inline @click="remove(announcement)"><i class="fas fa-trash-alt"></i> {{ $ts.remove }}</MkButton>
-					</div>
-				</div>
-			</section>
+	<MkButton @click="add()" primary style="margin: 0 auto 16px auto;"><i class="fas fa-plus"></i> {{ $ts.add }}</MkButton>
+	<section class="_card _gap announcements" v-for="announcement in announcements">
+		<div class="_content announcement">
+			<MkInput v-model:value="announcement.title">
+				<span>{{ $ts.title }}</span>
+			</MkInput>
+			<MkTextarea v-model:value="announcement.text">
+				<span>{{ $ts.text }}</span>
+			</MkTextarea>
+			<MkInput v-model:value="announcement.imageUrl">
+				<span>{{ $ts.imageUrl }}</span>
+			</MkInput>
+			<p v-if="announcement.reads">{{ $t('nUsersRead', { n: announcement.reads }) }}</p>
+			<div class="buttons">
+				<MkButton class="button" inline @click="save(announcement)" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
+				<MkButton class="button" inline @click="remove(announcement)"><i class="fas fa-trash-alt"></i> {{ $ts.remove }}</MkButton>
+			</div>
 		</div>
-	</div>
+	</section>
 </div>
 </template>
 
@@ -41,6 +37,8 @@ export default defineComponent({
 		MkTextarea,
 	},
 
+	emits: ['info'],
+
 	data() {
 		return {
 			[symbols.PAGE_INFO]: {
@@ -57,6 +55,10 @@ export default defineComponent({
 		});
 	},
 
+	mounted() {
+		this.$emit('info', this[symbols.PAGE_INFO]);
+	},
+
 	methods: {
 		add() {
 			this.announcements.unshift({
@@ -109,3 +111,9 @@ export default defineComponent({
 	}
 });
 </script>
+
+<style lang="scss" scoped>
+.ztgjmzrw {
+	margin: var(--margin);
+}
+</style>
diff --git a/src/client/pages/instance/bot-protection.vue b/src/client/pages/instance/bot-protection.vue
new file mode 100644
index 0000000000..449b8a233d
--- /dev/null
+++ b/src/client/pages/instance/bot-protection.vue
@@ -0,0 +1,138 @@
+<template>
+<FormBase>
+	<FormSuspense :p="init">
+		<FormRadios v-model="provider">
+			<template #desc><i class="fas fa-shield-alt"></i> {{ $ts.botProtection }}</template>
+			<option :value="null">{{ $ts.none }} ({{ $ts.notRecommended }})</option>
+			<option value="hcaptcha">hCaptcha</option>
+			<option value="recaptcha">reCAPTCHA</option>
+		</FormRadios>
+
+		<template v-if="provider === 'hcaptcha'">
+			<div class="_formItem _formNoConcat" v-sticky-container>
+				<div class="_formLabel">hCaptcha</div>
+				<div class="main">
+					<FormInput v-model:value="hcaptchaSiteKey">
+						<template #prefix><i class="fas fa-key"></i></template>
+						<span>{{ $ts.hcaptchaSiteKey }}</span>
+					</FormInput>
+					<FormInput v-model:value="hcaptchaSecretKey">
+						<template #prefix><i class="fas fa-key"></i></template>
+						<span>{{ $ts.hcaptchaSecretKey }}</span>
+					</FormInput>
+				</div>
+			</div>
+			<div class="_formItem _formNoConcat" v-sticky-container>
+				<div class="_formLabel">{{ $ts.preview }}</div>
+				<div class="_formPanel" style="padding: var(--formContentHMargin);">
+					<MkCaptcha provider="hcaptcha" :sitekey="hcaptchaSiteKey || '10000000-ffff-ffff-ffff-000000000001'"/>
+				</div>
+			</div>
+		</template>
+		<template v-else-if="provider === 'recaptcha'">
+			<div class="_formItem _formNoConcat" v-sticky-container>
+				<div class="_formLabel">reCAPTCHA</div>
+				<div class="main">
+					<FormInput v-model:value="recaptchaSiteKey">
+						<template #prefix><i class="fas fa-key"></i></template>
+						<span>{{ $ts.recaptchaSiteKey }}</span>
+					</FormInput>
+					<FormInput v-model:value="recaptchaSecretKey">
+						<template #prefix><i class="fas fa-key"></i></template>
+						<span>{{ $ts.recaptchaSecretKey }}</span>
+					</FormInput>
+				</div>
+			</div>
+			<div v-if="recaptchaSiteKey" class="_formItem _formNoConcat" v-sticky-container>
+				<div class="_formLabel">{{ $ts.preview }}</div>
+				<div class="_formPanel" style="padding: var(--formContentHMargin);">
+					<MkCaptcha provider="recaptcha" :sitekey="recaptchaSiteKey"/>
+				</div>
+			</div>
+		</template>
+
+		<FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+	</FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import FormRadios from '@client/components/form/radios.vue';
+import FormInput from '@client/components/form/input.vue';
+import FormButton from '@client/components/form/button.vue';
+import FormBase from '@client/components/form/base.vue';
+import FormGroup from '@client/components/form/group.vue';
+import FormInfo from '@client/components/form/info.vue';
+import FormSuspense from '@client/components/form/suspense.vue';
+import * as os from '@client/os';
+import * as symbols from '@client/symbols';
+import { fetchInstance } from '@client/instance';
+
+export default defineComponent({
+	components: {
+		FormRadios,
+		FormInput,
+		FormBase,
+		FormGroup,
+		FormButton,
+		FormInfo,
+		FormSuspense,
+		MkCaptcha: defineAsyncComponent(() => import('@client/components/captcha.vue')),
+	},
+
+	emits: ['info'],
+
+	data() {
+		return {
+			[symbols.PAGE_INFO]: {
+				title: this.$ts.botProtection,
+				icon: 'fas fa-shield-alt'
+			},
+			provider: null,
+			enableHcaptcha: false,
+			hcaptchaSiteKey: null,
+			hcaptchaSecretKey: null,
+			enableRecaptcha: false,
+			recaptchaSiteKey: null,
+			recaptchaSecretKey: null,
+		}
+	},
+
+	async mounted() {
+		this.$emit('info', this[symbols.PAGE_INFO]);
+	},
+
+	methods: {
+		async init() {
+			const meta = await os.api('meta', { detail: true });
+			this.enableHcaptcha = meta.enableHcaptcha;
+			this.hcaptchaSiteKey = meta.hcaptchaSiteKey;
+			this.hcaptchaSecretKey = meta.hcaptchaSecretKey;
+			this.enableRecaptcha = meta.enableRecaptcha;
+			this.recaptchaSiteKey = meta.recaptchaSiteKey;
+			this.recaptchaSecretKey = meta.recaptchaSecretKey;
+
+			this.provider = this.enableHcaptcha ? 'hcaptcha' : this.enableRecaptcha ? 'recaptcha' : null;
+
+			this.$watch(() => this.provider, () => {
+				this.enableHcaptcha = this.provider === 'hcaptcha';
+				this.enableRecaptcha = this.provider === 'recaptcha';
+			});
+		},
+	
+		save() {
+			os.apiWithDialog('admin/update-meta', {
+				enableHcaptcha: this.enableHcaptcha,
+				hcaptchaSiteKey: this.hcaptchaSiteKey,
+				hcaptchaSecretKey: this.hcaptchaSecretKey,
+				enableRecaptcha: this.enableRecaptcha,
+				recaptchaSiteKey: this.recaptchaSiteKey,
+				recaptchaSecretKey: this.recaptchaSecretKey,
+			}).then(() => {
+				fetchInstance();
+			});
+		}
+	}
+});
+</script>
diff --git a/src/client/pages/instance/database.vue b/src/client/pages/instance/database.vue
new file mode 100644
index 0000000000..a41d61ce2b
--- /dev/null
+++ b/src/client/pages/instance/database.vue
@@ -0,0 +1,60 @@
+<template>
+<FormBase>
+	<FormSuspense :p="databasePromiseFactory" v-slot="{ result: database }">
+		<FormGroup v-for="table in database" :key="table[0]">
+			<template #label>{{ table[0] }}</template>
+			<FormKeyValueView>
+				<template #key>Size</template>
+				<template #value>{{ bytes(table[1].size) }}</template>
+			</FormKeyValueView>
+			<FormKeyValueView>
+				<template #key>Records</template>
+				<template #value>{{ number(table[1].count) }}</template>
+			</FormKeyValueView>
+		</FormGroup>
+	</FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSuspense from '@client/components/form/suspense.vue';
+import FormKeyValueView from '@client/components/form/key-value-view.vue';
+import FormLink from '@client/components/form/link.vue';
+import FormBase from '@client/components/form/base.vue';
+import FormGroup from '@client/components/form/group.vue';
+import * as os from '@client/os';
+import * as symbols from '@client/symbols';
+import bytes from '@client/filters/bytes';
+import number from '@client/filters/number';
+
+export default defineComponent({
+	components: {
+		FormSuspense,
+		FormKeyValueView,
+		FormBase,
+		FormGroup,
+		FormLink,
+	},
+
+	emits: ['info'],
+
+	data() {
+		return {
+			[symbols.PAGE_INFO]: {
+				title: this.$ts.database,
+				icon: 'fas fa-database'
+			},
+			databasePromiseFactory: () => os.api('admin/get-table-stats', {}).then(res => Object.entries(res).sort((a, b) => b[1].size - a[1].size)),
+		}
+	},
+
+	mounted() {
+		this.$emit('info', this[symbols.PAGE_INFO]);
+	},
+
+	methods: {
+		bytes, number,
+	}
+});
+</script>
diff --git a/src/client/pages/instance/email-settings.vue b/src/client/pages/instance/email-settings.vue
new file mode 100644
index 0000000000..9965a1420f
--- /dev/null
+++ b/src/client/pages/instance/email-settings.vue
@@ -0,0 +1,127 @@
+<template>
+<FormBase>
+	<FormSuspense :p="init">
+		<FormSwitch v-model:value="enableEmail">{{ $ts.enableEmail }}<template #desc>{{ $ts.emailConfigInfo }}</template></FormSwitch>
+
+		<template v-if="enableEmail">
+			<FormInput v-model:value="email" type="email">
+				<span>{{ $ts.emailAddress }}</span>
+			</FormInput>
+
+			<div class="_formItem _formNoConcat" v-sticky-container>
+				<div class="_formLabel">{{ $ts.smtpConfig }}</div>
+				<div class="main">
+					<FormInput v-model:value="smtpHost">
+						<span>{{ $ts.smtpHost }}</span>
+					</FormInput>
+					<FormInput v-model:value="smtpPort" type="number">
+						<span>{{ $ts.smtpPort }}</span>
+					</FormInput>
+					<FormInput v-model:value="smtpUser">
+						<span>{{ $ts.smtpUser }}</span>
+					</FormInput>
+					<FormInput v-model:value="smtpPass" type="password">
+						<span>{{ $ts.smtpPass }}</span>
+					</FormInput>
+					<FormInfo>{{ $ts.emptyToDisableSmtpAuth }}</FormInfo>
+					<FormSwitch v-model:value="smtpSecure">{{ $ts.smtpSecure }}<template #desc>{{ $ts.smtpSecureInfo }}</template></FormSwitch>
+				</div>
+			</div>
+
+			<FormButton @click="testEmail">{{ $ts.testEmail }}</FormButton>
+		</template>
+
+		<FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+	</FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSwitch from '@client/components/form/switch.vue';
+import FormInput from '@client/components/form/input.vue';
+import FormButton from '@client/components/form/button.vue';
+import FormBase from '@client/components/form/base.vue';
+import FormGroup from '@client/components/form/group.vue';
+import FormInfo from '@client/components/form/info.vue';
+import FormSuspense from '@client/components/form/suspense.vue';
+import * as os from '@client/os';
+import * as symbols from '@client/symbols';
+import { fetchInstance } from '@client/instance';
+
+export default defineComponent({
+	components: {
+		FormSwitch,
+		FormInput,
+		FormBase,
+		FormGroup,
+		FormButton,
+		FormInfo,
+		FormSuspense,
+	},
+
+	emits: ['info'],
+
+	data() {
+		return {
+			[symbols.PAGE_INFO]: {
+				title: this.$ts.emailServer,
+				icon: 'fas fa-envelope'
+			},
+			enableEmail: false,
+			email: null,
+			smtpSecure: false,
+			smtpHost: '',
+			smtpPort: 0,
+			smtpUser: '',
+			smtpPass: '',
+		}
+	},
+
+	async mounted() {
+		this.$emit('info', this[symbols.PAGE_INFO]);
+	},
+
+	methods: {
+		async init() {
+			const meta = await os.api('meta', { detail: true });
+			this.enableEmail = meta.enableEmail;
+			this.email = meta.email;
+			this.smtpSecure = meta.smtpSecure;
+			this.smtpHost = meta.smtpHost;
+			this.smtpPort = meta.smtpPort;
+			this.smtpUser = meta.smtpUser;
+			this.smtpPass = meta.smtpPass;
+		},
+
+		async testEmail() {
+			const { canceled, result: destination } = await os.dialog({
+				title: this.$ts.destination,
+				input: {
+					placeholder: this.$instance.maintainerEmail
+				}
+			});
+			if (canceled) return;
+			os.apiWithDialog('admin/send-email', {
+				to: destination,
+				subject: 'Test email',
+				text: 'Yo'
+			});
+		},
+
+		save() {
+			os.apiWithDialog('admin/update-meta', {
+				enableEmail: this.enableEmail,
+				email: this.email,
+				smtpSecure: this.smtpSecure,
+				smtpHost: this.smtpHost,
+				smtpPort: this.smtpPort,
+				smtpUser: this.smtpUser,
+				smtpPass: this.smtpPass,
+			}).then(() => {
+				fetchInstance();
+			});
+		}
+	}
+});
+</script>
diff --git a/src/client/pages/instance/emojis.vue b/src/client/pages/instance/emojis.vue
index 88bebb40e0..fd641703cb 100644
--- a/src/client/pages/instance/emojis.vue
+++ b/src/client/pages/instance/emojis.vue
@@ -1,50 +1,46 @@
 <template>
-<div class="mk-instance-emojis">
-	<div class="_section" style="padding: 0;">
-		<MkTab v-model:value="tab">
-			<option value="local">{{ $ts.local }}</option>
-			<option value="remote">{{ $ts.remote }}</option>
-		</MkTab>
+<div class="ogwlenmc">
+	<MkTab v-model:value="tab">
+		<option value="local">{{ $ts.local }}</option>
+		<option value="remote">{{ $ts.remote }}</option>
+	</MkTab>
+
+	<div class="local" v-if="tab === 'local'">
+		<MkButton primary @click="add" style="margin: var(--margin) auto;"><i class="fas fa-plus"></i> {{ $ts.addEmoji }}</MkButton>
+		<MkInput v-model:value="query" :debounce="true" type="search" style="margin: var(--margin);"><template #icon><i class="fas fa-search"></i></template><span>{{ $ts.search }}</span></MkInput>
+		<MkPagination :pagination="pagination" ref="emojis">
+			<template #empty><span>{{ $ts.noCustomEmojis }}</span></template>
+			<template #default="{items}">
+				<div class="ldhfsamy">
+					<button class="emoji _panel _button" v-for="emoji in items" :key="emoji.id" @click="edit(emoji)">
+						<img :src="emoji.url" class="img" :alt="emoji.name"/>
+						<div class="body">
+							<div class="name _monospace">{{ emoji.name }}</div>
+							<div class="info">{{ emoji.category }}</div>
+						</div>
+					</button>
+				</div>
+			</template>
+		</MkPagination>
 	</div>
 
-	<div class="_section">
-		<div class="local" v-if="tab === 'local'">
-			<MkButton primary @click="add" style="margin: 0 auto var(--margin) auto;"><i class="fas fa-plus"></i> {{ $ts.addEmoji }}</MkButton>
-			<MkInput v-model:value="query" :debounce="true" type="search"><template #icon><i class="fas fa-search"></i></template><span>{{ $ts.search }}</span></MkInput>
-			<MkPagination :pagination="pagination" ref="emojis">
-				<template #empty><span>{{ $ts.noCustomEmojis }}</span></template>
-				<template #default="{items}">
-					<div class="emojis">
-						<button class="emoji _panel _button" v-for="emoji in items" :key="emoji.id" @click="edit(emoji)">
-							<img :src="emoji.url" class="img" :alt="emoji.name"/>
-							<div class="body">
-								<div class="name">{{ emoji.name }}</div>
-								<div class="info">{{ emoji.category }}</div>
-							</div>
-						</button>
-					</div>
-				</template>
-			</MkPagination>
-		</div>
-
-		<div class="remote" v-else-if="tab === 'remote'">
-			<MkInput v-model:value="queryRemote" :debounce="true" type="search"><template #icon><i class="fas fa-search"></i></template><span>{{ $ts.search }}</span></MkInput>
-			<MkInput v-model:value="host" :debounce="true"><span>{{ $ts.host }}</span></MkInput>
-			<MkPagination :pagination="remotePagination" ref="remoteEmojis">
-				<template #empty><span>{{ $ts.noCustomEmojis }}</span></template>
-				<template #default="{items}">
-					<div class="emojis">
-						<div class="emoji _panel _button" v-for="emoji in items" :key="emoji.id" @click="remoteMenu(emoji, $event)">
-							<img :src="emoji.url" class="img" :alt="emoji.name"/>
-							<div class="body">
-								<div class="name">{{ emoji.name }}</div>
-								<div class="info">{{ emoji.host }}</div>
-							</div>
+	<div class="remote" v-else-if="tab === 'remote'">
+		<MkInput v-model:value="queryRemote" :debounce="true" type="search" style="margin: var(--margin);"><template #icon><i class="fas fa-search"></i></template><span>{{ $ts.search }}</span></MkInput>
+		<MkInput v-model:value="host" :debounce="true" style="margin: var(--margin);"><span>{{ $ts.host }}</span></MkInput>
+		<MkPagination :pagination="remotePagination" ref="remoteEmojis">
+			<template #empty><span>{{ $ts.noCustomEmojis }}</span></template>
+			<template #default="{items}">
+				<div class="ldhfsamy">
+					<div class="emoji _panel _button" v-for="emoji in items" :key="emoji.id" @click="remoteMenu(emoji, $event)">
+						<img :src="emoji.url" class="img" :alt="emoji.name"/>
+						<div class="body">
+							<div class="name _monospace">{{ emoji.name }}</div>
+							<div class="info">{{ emoji.host }}</div>
 						</div>
 					</div>
-				</template>
-			</MkPagination>
-		</div>
+				</div>
+			</template>
+		</MkPagination>
 	</div>
 </div>
 </template>
@@ -67,6 +63,8 @@ export default defineComponent({
 		MkPagination,
 	},
 
+	emits: ['info'],
+
 	data() {
 		return {
 			[symbols.PAGE_INFO]: {
@@ -99,6 +97,10 @@ export default defineComponent({
 		}
 	},
 
+	async mounted() {
+		this.$emit('info', this[symbols.PAGE_INFO]);
+	},
+
 	methods: {
 		async add(e) {
 			const files = await selectFile(e.currentTarget || e.target, null, true);
@@ -150,85 +152,86 @@ export default defineComponent({
 </script>
 
 <style lang="scss" scoped>
-.mk-instance-emojis {
-	> ._section {
-		> .local {
-			.emojis {
-				display: grid;
-				grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
-				grid-gap: var(--margin);
-		
-				> .emoji {
-					display: flex;
-					align-items: center;
-					padding: 12px;
-					text-align: left;
-
-					&:hover {
-						color: var(--accent);
-					}
+.ogwlenmc {
+	> .local {
+		.ldhfsamy {
+			display: grid;
+			grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
+			grid-gap: 12px;
+			margin: var(--margin);
+	
+			> .emoji {
+				display: flex;
+				align-items: center;
+				padding: 12px;
+				text-align: left;
+
+				&:hover {
+					color: var(--accent);
+				}
 
-					> .img {
-						width: 42px;
-						height: 42px;
-					}
+				> .img {
+					width: 42px;
+					height: 42px;
+				}
 
-					> .body {
-						padding: 0 0 0 8px;
-						white-space: nowrap;
-						overflow: hidden;
+				> .body {
+					padding: 0 0 0 8px;
+					white-space: nowrap;
+					overflow: hidden;
 
-						> .name {
-							text-overflow: ellipsis;
-							overflow: hidden;
-						}
+					> .name {
+						text-overflow: ellipsis;
+						overflow: hidden;
+					}
 
-						> .info {
-							opacity: 0.5;
-							text-overflow: ellipsis;
-							overflow: hidden;
-						}
+					> .info {
+						opacity: 0.5;
+						text-overflow: ellipsis;
+						overflow: hidden;
 					}
 				}
 			}
 		}
+	}
 
-		> .remote {
-			.emojis {
-				display: grid;
-				grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
-				grid-gap: var(--margin);
+	> .remote {
+		.ldhfsamy {
+			display: grid;
+			grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
+			grid-gap: 12px;
+			margin: var(--margin);
+
+			> .emoji {
+				display: flex;
+				align-items: center;
+				padding: 12px;
+				text-align: left;
+
+				&:hover {
+					color: var(--accent);
+				}
 
-				> .emoji {
-					display: flex;
-					align-items: center;
-					padding: 12px;
-					text-align: left;
+				> .img {
+					width: 32px;
+					height: 32px;
+				}
 
-					&:hover {
-						color: var(--accent);
-					}
+				> .body {
+					padding: 0 0 0 8px;
+					white-space: nowrap;
+					overflow: hidden;
 
-					> .img {
-						width: 32px;
-						height: 32px;
+					> .name {
+						text-overflow: ellipsis;
+						overflow: hidden;
 					}
 
-					> .body {
-						padding: 0 0 0 8px;
-						white-space: nowrap;
+					> .info {
+						opacity: 0.5;
+						font-size: 90%;
+						text-overflow: ellipsis;
 						overflow: hidden;
-
-						> .name {
-							text-overflow: ellipsis;
-							overflow: hidden;
-						}
-
-						> .info {
-							opacity: 0.5;
-							text-overflow: ellipsis;
-							overflow: hidden;
-						}
 					}
 				}
 			}
diff --git a/src/client/pages/instance/federation.vue b/src/client/pages/instance/federation.vue
index 1b69fc291e..96f72fed44 100644
--- a/src/client/pages/instance/federation.vue
+++ b/src/client/pages/instance/federation.vue
@@ -1,60 +1,55 @@
 <template>
-<div>
-	<div class="_section">
-		<div class="_content">
-			<MkInput v-model:value="host" :debounce="true"><span>{{ $ts.host }}</span></MkInput>
-			<div class="inputs" style="display: flex;">
-				<MkSelect v-model:value="state" style="margin: 0; flex: 1;">
-					<template #label>{{ $ts.state }}</template>
-					<option value="all">{{ $ts.all }}</option>
-					<option value="federating">{{ $ts.federating }}</option>
-					<option value="subscribing">{{ $ts.subscribing }}</option>
-					<option value="publishing">{{ $ts.publishing }}</option>
-					<option value="suspended">{{ $ts.suspended }}</option>
-					<option value="blocked">{{ $ts.blocked }}</option>
-					<option value="notResponding">{{ $ts.notResponding }}</option>
-				</MkSelect>
-				<MkSelect v-model:value="sort" style="margin: 0; flex: 1;">
-					<template #label>{{ $ts.sort }}</template>
-					<option value="+pubSub">{{ $ts.pubSub }} ({{ $ts.descendingOrder }})</option>
-					<option value="-pubSub">{{ $ts.pubSub }} ({{ $ts.ascendingOrder }})</option>
-					<option value="+notes">{{ $ts.notes }} ({{ $ts.descendingOrder }})</option>
-					<option value="-notes">{{ $ts.notes }} ({{ $ts.ascendingOrder }})</option>
-					<option value="+users">{{ $ts.users }} ({{ $ts.descendingOrder }})</option>
-					<option value="-users">{{ $ts.users }} ({{ $ts.ascendingOrder }})</option>
-					<option value="+following">{{ $ts.following }} ({{ $ts.descendingOrder }})</option>
-					<option value="-following">{{ $ts.following }} ({{ $ts.ascendingOrder }})</option>
-					<option value="+followers">{{ $ts.followers }} ({{ $ts.descendingOrder }})</option>
-					<option value="-followers">{{ $ts.followers }} ({{ $ts.ascendingOrder }})</option>
-					<option value="+caughtAt">{{ $ts.caughtAt }} ({{ $ts.descendingOrder }})</option>
-					<option value="-caughtAt">{{ $ts.caughtAt }} ({{ $ts.ascendingOrder }})</option>
-					<option value="+lastCommunicatedAt">{{ $ts.lastCommunicatedAt }} ({{ $ts.descendingOrder }})</option>
-					<option value="-lastCommunicatedAt">{{ $ts.lastCommunicatedAt }} ({{ $ts.ascendingOrder }})</option>
-					<option value="+driveUsage">{{ $ts.driveUsage }} ({{ $ts.descendingOrder }})</option>
-					<option value="-driveUsage">{{ $ts.driveUsage }} ({{ $ts.ascendingOrder }})</option>
-					<option value="+driveFiles">{{ $ts.driveFiles }} ({{ $ts.descendingOrder }})</option>
-					<option value="-driveFiles">{{ $ts.driveFiles }} ({{ $ts.ascendingOrder }})</option>
-				</MkSelect>
-			</div>
+<div class="enuoauvw">
+	<div class="query">
+		<MkInput v-model:value="host" :debounce="true"><span>{{ $ts.host }}</span></MkInput>
+		<div class="inputs" style="display: flex;">
+			<MkSelect v-model:value="state" style="margin: 0; flex: 1;">
+				<template #label>{{ $ts.state }}</template>
+				<option value="all">{{ $ts.all }}</option>
+				<option value="federating">{{ $ts.federating }}</option>
+				<option value="subscribing">{{ $ts.subscribing }}</option>
+				<option value="publishing">{{ $ts.publishing }}</option>
+				<option value="suspended">{{ $ts.suspended }}</option>
+				<option value="blocked">{{ $ts.blocked }}</option>
+				<option value="notResponding">{{ $ts.notResponding }}</option>
+			</MkSelect>
+			<MkSelect v-model:value="sort" style="margin: 0; flex: 1;">
+				<template #label>{{ $ts.sort }}</template>
+				<option value="+pubSub">{{ $ts.pubSub }} ({{ $ts.descendingOrder }})</option>
+				<option value="-pubSub">{{ $ts.pubSub }} ({{ $ts.ascendingOrder }})</option>
+				<option value="+notes">{{ $ts.notes }} ({{ $ts.descendingOrder }})</option>
+				<option value="-notes">{{ $ts.notes }} ({{ $ts.ascendingOrder }})</option>
+				<option value="+users">{{ $ts.users }} ({{ $ts.descendingOrder }})</option>
+				<option value="-users">{{ $ts.users }} ({{ $ts.ascendingOrder }})</option>
+				<option value="+following">{{ $ts.following }} ({{ $ts.descendingOrder }})</option>
+				<option value="-following">{{ $ts.following }} ({{ $ts.ascendingOrder }})</option>
+				<option value="+followers">{{ $ts.followers }} ({{ $ts.descendingOrder }})</option>
+				<option value="-followers">{{ $ts.followers }} ({{ $ts.ascendingOrder }})</option>
+				<option value="+caughtAt">{{ $ts.caughtAt }} ({{ $ts.descendingOrder }})</option>
+				<option value="-caughtAt">{{ $ts.caughtAt }} ({{ $ts.ascendingOrder }})</option>
+				<option value="+lastCommunicatedAt">{{ $ts.lastCommunicatedAt }} ({{ $ts.descendingOrder }})</option>
+				<option value="-lastCommunicatedAt">{{ $ts.lastCommunicatedAt }} ({{ $ts.ascendingOrder }})</option>
+				<option value="+driveUsage">{{ $ts.driveUsage }} ({{ $ts.descendingOrder }})</option>
+				<option value="-driveUsage">{{ $ts.driveUsage }} ({{ $ts.ascendingOrder }})</option>
+				<option value="+driveFiles">{{ $ts.driveFiles }} ({{ $ts.descendingOrder }})</option>
+				<option value="-driveFiles">{{ $ts.driveFiles }} ({{ $ts.ascendingOrder }})</option>
+			</MkSelect>
 		</div>
 	</div>
-	<div class="_section">
-		<div class="_content">
-			<MkPagination :pagination="pagination" #default="{items}" ref="instances" :key="host + state">
-				<div class="ppgwaixt _panel" v-for="instance in items" :key="instance.id" @click="info(instance)">
-					<div class="host"><i class="fas fa-circle indicator" :class="getStatus(instance)"></i><b>{{ instance.host }}</b></div>
-					<div class="status">
-						<span class="sub" v-if="instance.followersCount > 0"><i class="fas fa-caret-down icon"></i>Sub</span>
-						<span class="sub" v-else><i class="fas fa-caret-down icon"></i>-</span>
-						<span class="pub" v-if="instance.followingCount > 0"><i class="fas fa-caret-up icon"></i>Pub</span>
-						<span class="pub" v-else><i class="fas fa-caret-up icon"></i>-</span>
-						<span class="lastCommunicatedAt"><i class="fas fa-exchange-alt icon"></i><MkTime :time="instance.lastCommunicatedAt"/></span>
-						<span class="latestStatus"><i class="fas fa-traffic-light icon"></i>{{ instance.latestStatus || '-' }}</span>
-					</div>
-				</div>
-			</MkPagination>
+
+	<MkPagination :pagination="pagination" #default="{items}" ref="instances" :key="host + state">
+		<div class="ppgwaixt _block" v-for="instance in items" :key="instance.id" @click="info(instance)">
+			<div class="host"><i class="fas fa-circle indicator" :class="getStatus(instance)"></i><b>{{ instance.host }}</b></div>
+			<div class="status">
+				<span class="sub" v-if="instance.followersCount > 0"><i class="fas fa-caret-down icon"></i>Sub</span>
+				<span class="sub" v-else><i class="fas fa-caret-down icon"></i>-</span>
+				<span class="pub" v-if="instance.followingCount > 0"><i class="fas fa-caret-up icon"></i>Pub</span>
+				<span class="pub" v-else><i class="fas fa-caret-up icon"></i>-</span>
+				<span class="lastCommunicatedAt"><i class="fas fa-exchange-alt icon"></i><MkTime :time="instance.lastCommunicatedAt"/></span>
+				<span class="latestStatus"><i class="fas fa-traffic-light icon"></i>{{ instance.latestStatus || '-' }}</span>
+			</div>
 		</div>
-	</div>
+	</MkPagination>
 </div>
 </template>
 
@@ -76,6 +71,8 @@ export default defineComponent({
 		MkPagination,
 	},
 
+	emits: ['info'],
+
 	data() {
 		return {
 			[symbols.PAGE_INFO]: {
@@ -114,6 +111,10 @@ export default defineComponent({
 		}
 	},
 
+	mounted() {
+		this.$emit('info', this[symbols.PAGE_INFO]);
+	},
+
 	methods: {
 		getStatus(instance) {
 			if (instance.isSuspended) return 'off';
@@ -131,6 +132,12 @@ export default defineComponent({
 </script>
 
 <style lang="scss" scoped>
+.enuoauvw {
+	> .query {
+		margin: var(--margin);
+	}
+}
+
 .ppgwaixt {
 	cursor: pointer;
 	padding: 16px;
diff --git a/src/client/pages/instance/file-dialog.vue b/src/client/pages/instance/file-dialog.vue
index 1220a5193f..ae6755465c 100644
--- a/src/client/pages/instance/file-dialog.vue
+++ b/src/client/pages/instance/file-dialog.vue
@@ -82,9 +82,7 @@ export default defineComponent({
 		},
 
 		showUser() {
-			os.popup(import('./user-dialog.vue'), {
-				userId: this.file.userId
-			}, {}, 'closed');
+			os.pageWindow(`/instance/user/${this.file.userId}`);
 		},
 
 		async del() {
diff --git a/src/client/pages/instance/files-settings.vue b/src/client/pages/instance/files-settings.vue
new file mode 100644
index 0000000000..614c7d4dbb
--- /dev/null
+++ b/src/client/pages/instance/files-settings.vue
@@ -0,0 +1,92 @@
+<template>
+<FormBase>
+	<FormSuspense :p="init">
+		<FormSwitch v-model:value="cacheRemoteFiles">
+			{{ $ts.cacheRemoteFiles }}
+			<template #desc>{{ $ts.cacheRemoteFilesDescription }}</template>
+		</FormSwitch>
+
+		<FormSwitch v-model:value="proxyRemoteFiles">
+			{{ $ts.proxyRemoteFiles }}
+			<template #desc>{{ $ts.proxyRemoteFilesDescription }}</template>
+		</FormSwitch>
+
+		<FormInput v-model:value="localDriveCapacityMb" type="number">
+			<span>{{ $ts.driveCapacityPerLocalAccount }}</span>
+			<template #suffix>MB</template>
+			<template #desc>{{ $ts.inMb }}</template>
+		</FormInput>
+
+		<FormInput v-model:value="remoteDriveCapacityMb" type="number" :disabled="!cacheRemoteFiles">
+			<span>{{ $ts.driveCapacityPerRemoteAccount }}</span>
+			<template #suffix>MB</template>
+			<template #desc>{{ $ts.inMb }}</template>
+		</FormInput>
+
+		<FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+	</FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSwitch from '@client/components/form/switch.vue';
+import FormInput from '@client/components/form/input.vue';
+import FormButton from '@client/components/form/button.vue';
+import FormBase from '@client/components/form/base.vue';
+import FormGroup from '@client/components/form/group.vue';
+import FormSuspense from '@client/components/form/suspense.vue';
+import * as os from '@client/os';
+import * as symbols from '@client/symbols';
+import { fetchInstance } from '@client/instance';
+
+export default defineComponent({
+	components: {
+		FormSwitch,
+		FormInput,
+		FormBase,
+		FormGroup,
+		FormButton,
+		FormSuspense,
+	},
+
+	emits: ['info'],
+
+	data() {
+		return {
+			[symbols.PAGE_INFO]: {
+				title: this.$ts.files,
+				icon: 'fas fa-cloud'
+			},
+			cacheRemoteFiles: false,
+			proxyRemoteFiles: false,
+			localDriveCapacityMb: 0,
+			remoteDriveCapacityMb: 0,
+		}
+	},
+
+	async mounted() {
+		this.$emit('info', this[symbols.PAGE_INFO]);
+	},
+
+	methods: {
+		async init() {
+			const meta = await os.api('meta', { detail: true });
+			this.cacheRemoteFiles = meta.cacheRemoteFiles;
+			this.proxyRemoteFiles = meta.proxyRemoteFiles;
+			this.localDriveCapacityMb = meta.driveCapacityPerLocalUserMb;
+			this.remoteDriveCapacityMb = meta.driveCapacityPerRemoteUserMb;
+		},
+		save() {
+			os.apiWithDialog('admin/update-meta', {
+				cacheRemoteFiles: this.cacheRemoteFiles,
+				proxyRemoteFiles: this.proxyRemoteFiles,
+				localDriveCapacityMb: parseInt(this.localDriveCapacityMb, 10),
+				remoteDriveCapacityMb: parseInt(this.remoteDriveCapacityMb, 10),
+			}).then(() => {
+				fetchInstance();
+			});
+		}
+	}
+});
+</script>
diff --git a/src/client/pages/instance/files.vue b/src/client/pages/instance/files.vue
index ed46dd466c..427c5b411a 100644
--- a/src/client/pages/instance/files.vue
+++ b/src/client/pages/instance/files.vue
@@ -80,6 +80,8 @@ export default defineComponent({
 		MkDriveFileThumbnail,
 	},
 
+	emits: ['info'],
+
 	data() {
 		return {
 			[symbols.PAGE_INFO]: {
@@ -114,6 +116,10 @@ export default defineComponent({
 		},
 	},
 
+	mounted() {
+		this.$emit('info', this[symbols.PAGE_INFO]);
+	},
+
 	methods: {
 		clear() {
 			os.dialog({
@@ -153,6 +159,8 @@ export default defineComponent({
 
 <style lang="scss" scoped>
 .xrmjdkdw {
+	margin: var(--margin);
+
 	.urempief {
 		margin-top: var(--margin);
 
diff --git a/src/client/pages/instance/index.metrics.vue b/src/client/pages/instance/index.metrics.vue
deleted file mode 100644
index 9dd115240e..0000000000
--- a/src/client/pages/instance/index.metrics.vue
+++ /dev/null
@@ -1,574 +0,0 @@
-<template>
-<div>
-	<MkFolder>
-		<template #header><i class="fas fa-heartbeat"></i> {{ $ts.metrics }}</template>
-		<div class="_section" style="padding: 0 var(--margin);">
-			<div class="_content">
-				<MkContainer :foldable="false" class="_gap">
-					<template #header><i class="fas fa-microchip"></i>{{ $ts.cpuAndMemory }}</template>
-					<!--
-					<template #func>
-						<button class="_button" @click="resume" :disabled="!paused"><i class="fas fa-play"></i></button>
-						<button class="_button" @click="pause" :disabled="paused"><i class="fas fa-pause"></i></button>
-					</template>
-					-->
-
-					<div class="_content" style="margin-top: -8px; margin-bottom: -12px;">
-						<canvas :ref="cpumem"></canvas>
-					</div>
-					<div class="_content" v-if="serverInfo">
-						<div class="_table">
-							<div class="_row">
-								<div class="_cell"><div class="_label">MEM total</div>{{ bytes(serverInfo.mem.total) }}</div>
-								<div class="_cell"><div class="_label">MEM used</div>{{ bytes(memUsage) }} ({{ (memUsage / serverInfo.mem.total * 100).toFixed(0) }}%)</div>
-								<div class="_cell"><div class="_label">MEM free</div>{{ bytes(serverInfo.mem.total - memUsage) }} ({{ ((serverInfo.mem.total - memUsage) / serverInfo.mem.total * 100).toFixed(0) }}%)</div>
-							</div>
-						</div>
-					</div>
-				</MkContainer>
-
-				<MkContainer :foldable="false" class="_gap">
-					<template #header><i class="fas fa-hdd"></i> {{ $ts.disk }}</template>
-					<!--
-					<template #func>
-						<button class="_button" @click="resume" :disabled="!paused"><i class="fas fa-play"></i></button>
-						<button class="_button" @click="pause" :disabled="paused"><i class="fas fa-pause"></i></button>
-					</template>
-					-->
-
-					<div class="_content" style="margin-top: -8px; margin-bottom: -12px;">
-						<canvas :ref="disk"></canvas>
-					</div>
-					<div class="_content" v-if="serverInfo">
-						<div class="_table">
-							<div class="_row">
-								<div class="_cell"><div class="_label">Disk total</div>{{ bytes(serverInfo.fs.total) }}</div>
-								<div class="_cell"><div class="_label">Disk used</div>{{ bytes(serverInfo.fs.used) }} ({{ (serverInfo.fs.used / serverInfo.fs.total * 100).toFixed(0) }}%)</div>
-								<div class="_cell"><div class="_label">Disk free</div>{{ bytes(serverInfo.fs.total - serverInfo.fs.used) }} ({{ ((serverInfo.fs.total - serverInfo.fs.used) / serverInfo.fs.total * 100).toFixed(0) }}%)</div>
-							</div>
-						</div>
-					</div>
-				</MkContainer>
-
-				<MkContainer :foldable="false" class="_gap">
-					<template #header><i class="fas fa-exchange-alt"></i> {{ $ts.network }}</template>
-					<!--
-					<template #func>
-						<button class="_button" @click="resume" :disabled="!paused"><i class="fas fa-play"></i></button>
-						<button class="_button" @click="pause" :disabled="paused"><i class="fas fa-pause"></i></button>
-					</template>
-					-->
-
-					<div class="_content" style="margin-top: -8px; margin-bottom: -12px;">
-						<canvas :ref="net"></canvas>
-					</div>
-					<div class="_content" v-if="serverInfo">
-						<div class="_table">
-							<div class="_row">
-								<div class="_cell"><div class="_label">Interface</div>{{ serverInfo.net.interface }}</div>
-							</div>
-						</div>
-					</div>
-				</MkContainer>
-			</div>
-		</div>
-	</MkFolder>
-
-	<MkFolder>
-		<template #header><i class="fas fa-clipboard-list"></i> {{ $ts.jobQueue }}</template>
-
-		<div class="vkyrmkwb" :style="{ gridTemplateRows: queueHeight }">
-			<MkContainer :foldable="false" :scrollable="true" :resize-base-el="() => $el">
-				<template #header><i class="fas fa-exclamation-triangle"></i> {{ $ts.delayed }}</template>
-
-				<div class="_content">
-					<div class="_keyValue" v-for="job in jobs" :key="job[0]">
-						<button class="_button" @click="showInstanceInfo(job[0])">{{ job[0] }}</button>
-						<div style="text-align: right;">{{ number(job[1]) }} jobs</div>
-					</div>
-				</div>
-			</MkContainer>
-			<XQueue :connection="queueConnection" domain="inbox" ref="queue" class="queue">
-				<template #title><i class="fas fa-exchange-alt"></i> In</template>
-			</XQueue>
-			<XQueue :connection="queueConnection" domain="deliver" class="queue">
-				<template #title><i class="fas fa-exchange-alt"></i> Out</template>
-			</XQueue>
-		</div>
-	</MkFolder>
-</div>
-</template>
-
-<script lang="ts">
-import { defineComponent, markRaw } from 'vue';
-import Chart from 'chart.js';
-import MkButton from '@client/components/ui/button.vue';
-import MkSelect from '@client/components/ui/select.vue';
-import MkInput from '@client/components/ui/input.vue';
-import MkContainer from '@client/components/ui/container.vue';
-import MkFolder from '@client/components/ui/folder.vue';
-import MkwFederation from '../../widgets/federation.vue';
-import { version, url } from '@client/config';
-import bytes from '../../filters/bytes';
-import number from '../../filters/number';
-import MkInstanceInfo from './instance.vue';
-
-const alpha = (hex, a) => {
-	const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
-	const r = parseInt(result[1], 16);
-	const g = parseInt(result[2], 16);
-	const b = parseInt(result[3], 16);
-	return `rgba(${r}, ${g}, ${b}, ${a})`;
-};
-import * as os from '@client/os';
-
-export default defineComponent({
-	components: {
-		MkButton,
-		MkSelect,
-		MkInput,
-		MkContainer,
-		MkFolder,
-		MkwFederation,
-	},
-
-	data() {
-		return {
-			version,
-			url,
-			stats: null,
-			serverInfo: null,
-			connection: null,
-			queueConnection: os.stream.useSharedConnection('queueStats'),
-			memUsage: 0,
-			chartCpuMem: null,
-			chartNet: null,
-			jobs: [],
-			logs: [],
-			logLevel: 'all',
-			logDomain: '',
-			modLogs: [],
-			dbInfo: null,
-			overviewHeight: '1fr',
-			queueHeight: '1fr',
-			paused: false,
-		}
-	},
-
-	computed: {
-		gridColor() {
-			// TODO: var(--panel)の色が暗いか明るいかで判定する
-			return this.$store.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
-		},
-	},
-
-	mounted() {
-		this.fetchJobs();
-
-		Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg');
-
-		os.api('admin/server-info', {}).then(res => {
-			this.serverInfo = res;
-
-			this.connection = os.stream.useSharedConnection('serverStats');
-			this.connection.on('stats', this.onStats);
-			this.connection.on('statsLog', this.onStatsLog);
-			this.connection.send('requestLog', {
-				id: Math.random().toString().substr(2, 8),
-				length: 150
-			});
-
-			this.$nextTick(() => {
-				this.queueConnection.send('requestLog', {
-					id: Math.random().toString().substr(2, 8),
-					length: 200
-				});
-			});
-		});
-	},
-
-	beforeUnmount() {
-		this.connection.off('stats', this.onStats);
-		this.connection.off('statsLog', this.onStatsLog);
-		this.connection.dispose();
-		this.queueConnection.dispose();
-	},
-
-	methods: {
-		cpumem(el) {
-			if (this.chartCpuMem != null) return;
-			this.chartCpuMem = markRaw(new Chart(el, {
-				type: 'line',
-				data: {
-					labels: [],
-					datasets: [{
-						label: 'CPU',
-						pointRadius: 0,
-						lineTension: 0,
-						borderWidth: 2,
-						borderColor: '#86b300',
-						backgroundColor: alpha('#86b300', 0.1),
-						data: []
-					}, {
-						label: 'MEM (active)',
-						pointRadius: 0,
-						lineTension: 0,
-						borderWidth: 2,
-						borderColor: '#935dbf',
-						backgroundColor: alpha('#935dbf', 0.02),
-						data: []
-					}, {
-						label: 'MEM (used)',
-						pointRadius: 0,
-						lineTension: 0,
-						borderWidth: 2,
-						borderColor: '#935dbf',
-						borderDash: [5, 5],
-						fill: false,
-						data: []
-					}]
-				},
-				options: {
-					aspectRatio: 3,
-					layout: {
-						padding: {
-							left: 0,
-							right: 0,
-							top: 8,
-							bottom: 0
-						}
-					},
-					legend: {
-						position: 'bottom',
-						labels: {
-							boxWidth: 16,
-						}
-					},
-					scales: {
-						xAxes: [{
-							gridLines: {
-								display: false,
-								color: this.gridColor,
-								zeroLineColor: this.gridColor,
-							},
-							ticks: {
-								display: false,
-							}
-						}],
-						yAxes: [{
-							position: 'right',
-							gridLines: {
-								display: true,
-								color: this.gridColor,
-								zeroLineColor: this.gridColor,
-							},
-							ticks: {
-								display: false,
-								max: 100
-							}
-						}]
-					},
-					tooltips: {
-						intersect: false,
-						mode: 'index',
-					}
-				}
-			}));
-		},
-
-		net(el) {
-			if (this.chartNet != null) return;
-			this.chartNet = markRaw(new Chart(el, {
-				type: 'line',
-				data: {
-					labels: [],
-					datasets: [{
-						label: 'In',
-						pointRadius: 0,
-						lineTension: 0,
-						borderWidth: 2,
-						borderColor: '#94a029',
-						backgroundColor: alpha('#94a029', 0.1),
-						data: []
-					}, {
-						label: 'Out',
-						pointRadius: 0,
-						lineTension: 0,
-						borderWidth: 2,
-						borderColor: '#ff9156',
-						backgroundColor: alpha('#ff9156', 0.1),
-						data: []
-					}]
-				},
-				options: {
-					aspectRatio: 3,
-					layout: {
-						padding: {
-							left: 0,
-							right: 0,
-							top: 8,
-							bottom: 0
-						}
-					},
-					legend: {
-						position: 'bottom',
-						labels: {
-							boxWidth: 16,
-						}
-					},
-					scales: {
-						xAxes: [{
-							gridLines: {
-								display: false,
-								color: this.gridColor,
-								zeroLineColor: this.gridColor,
-							},
-							ticks: {
-								display: false
-							}
-						}],
-						yAxes: [{
-							position: 'right',
-							gridLines: {
-								display: true,
-								color: this.gridColor,
-								zeroLineColor: this.gridColor,
-							},
-							ticks: {
-								display: false,
-							}
-						}]
-					},
-					tooltips: {
-						intersect: false,
-						mode: 'index',
-					}
-				}
-			}));
-		},
-
-		disk(el) {
-			if (this.chartDisk != null) return;
-			this.chartDisk = markRaw(new Chart(el, {
-				type: 'line',
-				data: {
-					labels: [],
-					datasets: [{
-						label: 'Read',
-						pointRadius: 0,
-						lineTension: 0,
-						borderWidth: 2,
-						borderColor: '#94a029',
-						backgroundColor: alpha('#94a029', 0.1),
-						data: []
-					}, {
-						label: 'Write',
-						pointRadius: 0,
-						lineTension: 0,
-						borderWidth: 2,
-						borderColor: '#ff9156',
-						backgroundColor: alpha('#ff9156', 0.1),
-						data: []
-					}]
-				},
-				options: {
-					aspectRatio: 3,
-					layout: {
-						padding: {
-							left: 0,
-							right: 0,
-							top: 8,
-							bottom: 0
-						}
-					},
-					legend: {
-						position: 'bottom',
-						labels: {
-							boxWidth: 16,
-						}
-					},
-					scales: {
-						xAxes: [{
-							gridLines: {
-								display: false,
-								color: this.gridColor,
-								zeroLineColor: this.gridColor,
-							},
-							ticks: {
-								display: false
-							}
-						}],
-						yAxes: [{
-							position: 'right',
-							gridLines: {
-								display: true,
-								color: this.gridColor,
-								zeroLineColor: this.gridColor,
-							},
-							ticks: {
-								display: false,
-							}
-						}]
-					},
-					tooltips: {
-						intersect: false,
-						mode: 'index',
-					}
-				}
-			}));
-		},
-
-		async showInstanceInfo(q) {
-			let instance = q;
-			if (typeof q === 'string') {
-				instance = await os.api('federation/show-instance', {
-					host: q
-				});
-			}
-			os.popup(MkInstanceInfo, {
-				instance: instance
-			}, {}, 'closed');
-		},
-
-		fetchJobs() {
-			os.api('admin/queue/deliver-delayed', {}).then(jobs => {
-				this.jobs = jobs;
-			});
-		},
-
-		onStats(stats) {
-			if (this.paused) return;
-
-			const cpu = (stats.cpu * 100).toFixed(0);
-			const memActive = (stats.mem.active / this.serverInfo.mem.total * 100).toFixed(0);
-			const memUsed = (stats.mem.used / this.serverInfo.mem.total * 100).toFixed(0);
-			this.memUsage = stats.mem.active;
-
-			this.chartCpuMem.data.labels.push('');
-			this.chartCpuMem.data.datasets[0].data.push(cpu);
-			this.chartCpuMem.data.datasets[1].data.push(memActive);
-			this.chartCpuMem.data.datasets[2].data.push(memUsed);
-			this.chartNet.data.labels.push('');
-			this.chartNet.data.datasets[0].data.push(stats.net.rx);
-			this.chartNet.data.datasets[1].data.push(stats.net.tx);
-			this.chartDisk.data.labels.push('');
-			this.chartDisk.data.datasets[0].data.push(stats.fs.r);
-			this.chartDisk.data.datasets[1].data.push(stats.fs.w);
-			if (this.chartCpuMem.data.datasets[0].data.length > 150) {
-				this.chartCpuMem.data.labels.shift();
-				this.chartCpuMem.data.datasets[0].data.shift();
-				this.chartCpuMem.data.datasets[1].data.shift();
-				this.chartCpuMem.data.datasets[2].data.shift();
-				this.chartNet.data.labels.shift();
-				this.chartNet.data.datasets[0].data.shift();
-				this.chartNet.data.datasets[1].data.shift();
-				this.chartDisk.data.labels.shift();
-				this.chartDisk.data.datasets[0].data.shift();
-				this.chartDisk.data.datasets[1].data.shift();
-			}
-			this.chartCpuMem.update();
-			this.chartNet.update();
-			this.chartDisk.update();
-		},
-
-		onStatsLog(statsLog) {
-			for (const stats of [...statsLog].reverse()) {
-				this.onStats(stats);
-			}
-		},
-
-		bytes,
-
-		number,
-
-		pause() {
-			this.paused = true;
-		},
-
-		resume() {
-			this.paused = false;
-		},
-	}
-});
-</script>
-
-<style lang="scss" scoped>
-.xhexznfu {
-	&.min-width_1000px {
-		.sboqnrfi {
-			display: grid;
-			grid-template-columns: 3.2fr 1fr;
-			grid-template-rows: 1fr;
-			gap: 16px 16px;
-
-			> .stats {
-				height: min-content;
-			}
-
-			> .column {
-				display: flex;
-				flex-direction: column;
-
-				> .info {
-					flex-shrink: 0;
-					flex-grow: 0;
-				}
-
-				> .db {
-					flex: 1;
-					flex-grow: 0;
-					height: 100%;
-				}
-
-				> .fed {
-					flex: 1;
-					flex-grow: 0;
-					height: 100%;
-				}
-
-				> *:not(:last-child) {
-					margin-bottom: var(--margin);
-				}
-			}
-		}
-
-		.segusily {
-			display: grid;
-			grid-template-columns: 1fr 1fr 1fr;
-			grid-template-rows: 1fr;
-			gap: 16px 16px;
-			padding: 0 16px;
-		}
-
-		.vkyrmkwb {
-			display: grid;
-			grid-template-columns: 0.5fr 1fr 1fr;
-			grid-template-rows: 1fr;
-			gap: 16px 16px;
-			margin-bottom: var(--margin);
-
-			> .queue {
-				height: min-content;
-			}
-
-			> * {
-				margin-bottom: 0;
-			}
-		}
-
-		.uwuemslx {
-			display: grid;
-			grid-template-columns: 2fr 3fr;
-			grid-template-rows: 1fr;
-			gap: 16px 16px;
-			height: 400px;
-		}
-	}
-
-	.vkyrmkwb {
-		> * {
-			margin-bottom: var(--margin);
-		}
-	}
-}
-</style>
diff --git a/src/client/pages/instance/index.vue b/src/client/pages/instance/index.vue
index f0240718aa..10406f339b 100644
--- a/src/client/pages/instance/index.vue
+++ b/src/client/pages/instance/index.vue
@@ -1,171 +1,239 @@
 <template>
-<div v-if="meta" v-show="page === 'index'" class="xhexznfu _section">
-	<MkFolder>
-		<template #header><i class="fas fa-tachometer-alt"></i> {{ $ts.overview }}</template>
-
-		<div class="sboqnrfi" :style="{ gridTemplateRows: overviewHeight }">
-			<MkInstanceStats :chart-limit="300" :detailed="true" class="_gap" ref="stats"/>
-
-			<MkContainer :foldable="true" class="_gap">
-				<template #header><i class="fas fa-info-circle"></i>{{ $ts.instanceInfo }}</template>
-
-				<div class="_content">
-					<div class="_keyValue"><b>Misskey</b><span>v{{ version }}</span></div>
-				</div>
-				<div class="_content" v-if="serverInfo">
-					<div class="_keyValue"><b>Node.js</b><span>{{ serverInfo.node }}</span></div>
-					<div class="_keyValue"><b>PostgreSQL</b><span>v{{ serverInfo.psql }}</span></div>
-					<div class="_keyValue"><b>Redis</b><span>v{{ serverInfo.redis }}</span></div>
+<div class="hiyeyicy" :class="{ wide: !narrow }" ref="el">
+	<div class="nav" v-if="!narrow || page == null">
+		<FormBase>
+			<FormGroup>
+				<div class="_formItem">
+					<div class="_formPanel lxpfedzu">
+						<img :src="$instance.iconUrl || '/favicon.ico'" alt="" class="icon"/>
+					</div>
 				</div>
-			</MkContainer>
-			
-			<MkContainer :foldable="true" :scrollable="true" class="_gap" style="height: 300px;">
-				<template #header><i class="fas fa-database"></i>{{ $ts.database }}</template>
-
-				<div class="_content" v-if="dbInfo">
-					<table style="border-collapse: collapse; width: 100%;">
-						<tr style="opacity: 0.7;">
-							<th style="text-align: left; padding: 0 8px 8px 0;">Table</th>
-							<th style="text-align: left; padding: 0 8px 8px 0;">Records</th>
-							<th style="text-align: left; padding: 0 0 8px 0;">Size</th>
-						</tr>
-						<tr v-for="table in dbInfo" :key="table[0]">
-							<th style="text-align: left; padding: 0 8px 0 0; word-break: break-all;">{{ table[0] }}</th>
-							<td style="padding: 0 8px 0 0;">{{ number(table[1].count) }}</td>
-							<td style="padding: 0; opacity: 0.7;">{{ bytes(table[1].size) }}</td>
-						</tr>
-					</table>
-				</div>
-			</MkContainer>
-		</div>
-	</MkFolder>
-</div>
-<div v-if="page === 'logs'" class="_section">
-	<MkFolder>
-		<template #header><i class="fas fa-stream"></i> {{ $ts.logs }}</template>
-
-		<div class="_keyValue" v-for="log in modLogs">
-			<b>{{ log.type }}</b><span>by {{ log.user.username }}</span><MkTime :time="log.createdAt" style="opacity: 0.7;"/>
-		</div>
-	</MkFolder>
-</div>
-<div v-if="page === 'metrics'">
-	<XMetrics/>
+				<FormLink :active="page === 'overview'" replace to="/instance/overview"><template #icon><i class="fas fa-tachometer-alt"></i></template>{{ $ts.overview }}</FormLink>
+			</FormGroup>
+			<FormGroup>
+				<template #label>{{ $ts.quickAction }}</template>
+				<FormButton @click="lookup"><i class="fas fa-search"></i> {{ $ts.lookup }}</FormButton>
+				<FormButton v-if="$instance.disableRegistration" @click="invite"><i class="fas fa-user"></i> {{ $ts.invite }}</FormButton>
+			</FormGroup>
+			<FormGroup>
+				<FormLink :active="page === 'users'" replace to="/instance/users"><template #icon><i class="fas fa-users"></i></template>{{ $ts.users }}</FormLink>
+				<FormLink :active="page === 'emojis'" replace to="/instance/emojis"><template #icon><i class="fas fa-laugh"></i></template>{{ $ts.customEmojis }}</FormLink>
+				<FormLink :active="page === 'federation'" replace to="/instance/federation"><template #icon><i class="fas fa-globe"></i></template>{{ $ts.federation }}</FormLink>
+				<FormLink :active="page === 'queue'" replace to="/instance/queue"><template #icon><i class="fas fa-clipboard-list"></i></template>{{ $ts.jobQueue }}</FormLink>
+				<FormLink :active="page === 'files'" replace to="/instance/files"><template #icon><i class="fas fa-cloud"></i></template>{{ $ts.files }}</FormLink>
+				<FormLink :active="page === 'announcements'" replace to="/instance/announcements"><template #icon><i class="fas fa-broadcast-tower"></i></template>{{ $ts.announcements }}</FormLink>
+				<FormLink :active="page === 'database'" replace to="/instance/database"><template #icon><i class="fas fa-database"></i></template>{{ $ts.database }}</FormLink>
+				<FormLink :active="page === 'abuses'" replace to="/instance/abuses"><template #icon><i class="fas fa-exclamation-circle"></i></template>{{ $ts.abuseReports }}</FormLink>
+			</FormGroup>
+			<FormGroup>
+				<template #label>{{ $ts.settings }}</template>
+				<FormLink :active="page === 'settings'" replace to="/instance/settings"><template #icon><i class="fas fa-cog"></i></template>{{ $ts.general }}</FormLink>
+				<FormLink :active="page === 'files-settings'" replace to="/instance/files-settings"><template #icon><i class="fas fa-cloud"></i></template>{{ $ts.files }}</FormLink>
+				<FormLink :active="page === 'email-settings'" replace to="/instance/email-settings"><template #icon><i class="fas fa-envelope"></i></template>{{ $ts.emailServer }}</FormLink>
+				<FormLink :active="page === 'object-storage'" replace to="/instance/object-storage"><template #icon><i class="fas fa-cloud"></i></template>{{ $ts.objectStorage }}</FormLink>
+				<FormLink :active="page === 'security'" replace to="/instance/security"><template #icon><i class="fas fa-lock"></i></template>{{ $ts.security }}</FormLink>
+				<FormLink :active="page === 'service-worker'" replace to="/instance/service-worker"><template #icon><i class="fas fa-bolt"></i></template>ServiceWorker</FormLink>
+				<FormLink :active="page === 'relays'" replace to="/instance/relays"><template #icon><i class="fas fa-globe"></i></template>{{ $ts.relays }}</FormLink>
+				<FormLink :active="page === 'integrations'" replace to="/instance/integrations"><template #icon><i class="fas fa-share-alt"></i></template>{{ $ts.integration }}</FormLink>
+				<FormLink :active="page === 'instance-block'" replace to="/instance/instance-block"><template #icon><i class="fas fa-ban"></i></template>{{ $ts.instanceBlocking }}</FormLink>
+				<FormLink :active="page === 'proxy-account'" replace to="/instance/proxy-account"><template #icon><i class="fas fa-ghost"></i></template>{{ $ts.proxyAccount }}</FormLink>
+				<FormLink :active="page === 'other-settings'" replace to="/instance/other-settings"><template #icon><i class="fas fa-cogs"></i></template>{{ $ts.other }}</FormLink>
+			</FormGroup>
+		</FormBase>
+	</div>
+	<div class="main">
+		<component :is="component" :key="page" @info="onInfo" v-bind="pageProps"/>
+	</div>
 </div>
 </template>
 
 <script lang="ts">
-import { computed, defineComponent, markRaw } from 'vue';
-import VueJsonPretty from 'vue-json-pretty';
-import MkInstanceStats from '@client/components/instance-stats.vue';
-import MkButton from '@client/components/ui/button.vue';
-import MkSelect from '@client/components/ui/select.vue';
-import MkInput from '@client/components/ui/input.vue';
-import MkContainer from '@client/components/ui/container.vue';
-import MkFolder from '@client/components/ui/folder.vue';
-import { version, url } from '@client/config';
-import bytes from '../../filters/bytes';
-import number from '../../filters/number';
-import MkInstanceInfo from './instance.vue';
-import XMetrics from './index.metrics.vue';
-import * as os from '@client/os';
+import { computed, defineAsyncComponent, defineComponent, nextTick, onMounted, reactive, ref, watch } from 'vue';
+import { i18n } from '@client/i18n';
+import FormLink from '@client/components/form/link.vue';
+import FormGroup from '@client/components/form/group.vue';
+import FormBase from '@client/components/form/base.vue';
+import FormButton from '@client/components/form/button.vue';
+import { scroll } from '@client/scripts/scroll';
 import * as symbols from '@client/symbols';
+import * as os from '@client/os';
+import { lookupUser } from '@client/scripts/lookup-user';
 
 export default defineComponent({
 	components: {
-		MkInstanceStats,
-		MkButton,
-		MkSelect,
-		MkInput,
-		MkContainer,
-		MkFolder,
-		XMetrics,
-		VueJsonPretty,
+		FormBase,
+		FormLink,
+		FormGroup,
+		FormButton,
 	},
 
-	data() {
-		return {
-			[symbols.PAGE_INFO]: {
-				tabs: [{
-					id: 'index',
-					title: null,
-					tooltip: this.$ts.instance,
-					icon: 'fas fa-server',
-					onClick: () => { this.page = 'index'; },
-					selected: computed(() => this.page === 'index')
-				}, {
-					id: 'metrics',
-					title: null,
-					tooltip: this.$ts.metrics,
-					icon: 'fas fa-heartbeat',
-					onClick: () => { this.page = 'metrics'; },
-					selected: computed(() => this.page === 'metrics')
-				}, {
-					id: 'logs',
-					title: null,
-					tooltip: this.$ts.logs,
-					icon: 'fas fa-stream',
-					onClick: () => { this.page = 'logs'; },
-					selected: computed(() => this.page === 'logs')
-				}]
-			},
-			page: 'index',
-			version,
-			url,
-			stats: null,
-			serverInfo: null,
-			modLogs: [],
-			dbInfo: null,
+	props: {
+		initialPage: {
+			type: String,
+			required: false
 		}
 	},
 
-	computed: {
-		meta() {
-			return this.$instance;
-		},
-	},
+	setup(props, context) {
+		const indexInfo = {
+			title: i18n.locale.instance,
+			icon: 'fas fa-cog'
+		};
+		const INFO = ref(indexInfo);
+		const page = ref(props.initialPage);
+		const narrow = ref(false);
+		const view = ref(null);
+		const el = ref(null);
+		const onInfo = (viewInfo) => {
+			INFO.value = viewInfo;
+		};
+		const pageProps = ref({});
+		const component = computed(() => {
+			if (page.value == null) return null;
+			switch (page.value) {
+				case 'overview': return defineAsyncComponent(() => import('./overview.vue'));
+				case 'users': return defineAsyncComponent(() => import('./users.vue'));
+				case 'emojis': return defineAsyncComponent(() => import('./emojis.vue'));
+				case 'federation': return defineAsyncComponent(() => import('./federation.vue'));
+				case 'queue': return defineAsyncComponent(() => import('./queue.vue'));
+				case 'files': return defineAsyncComponent(() => import('./files.vue'));
+				case 'announcements': return defineAsyncComponent(() => import('./announcements.vue'));
+				case 'database': return defineAsyncComponent(() => import('./database.vue'));
+				case 'abuses': return defineAsyncComponent(() => import('./abuses.vue'));
+				case 'settings': return defineAsyncComponent(() => import('./settings.vue'));
+				case 'files-settings': return defineAsyncComponent(() => import('./files-settings.vue'));
+				case 'email-settings': return defineAsyncComponent(() => import('./email-settings.vue'));
+				case 'object-storage': return defineAsyncComponent(() => import('./object-storage.vue'));
+				case 'security': return defineAsyncComponent(() => import('./security.vue'));
+				case 'bot-protection': return defineAsyncComponent(() => import('./bot-protection.vue'));
+				case 'service-worker': return defineAsyncComponent(() => import('./service-worker.vue'));
+				case 'relays': return defineAsyncComponent(() => import('./relays.vue'));
+				case 'integrations': return defineAsyncComponent(() => import('./integrations.vue'));
+				case 'integrations/twitter': return defineAsyncComponent(() => import('./integrations-twitter.vue'));
+				case 'integrations/github': return defineAsyncComponent(() => import('./integrations-github.vue'));
+				case 'integrations/discord': return defineAsyncComponent(() => import('./integrations-discord.vue'));
+				case 'instance-block': return defineAsyncComponent(() => import('./instance-block.vue'));
+				case 'proxy-account': return defineAsyncComponent(() => import('./proxy-account.vue'));
+				case 'other-settings': return defineAsyncComponent(() => import('./other-settings.vue'));
+			}
+		});
 
-	mounted() {
-		this.fetchJobs();
-		this.fetchModLogs();
+		watch(component, () => {
+			pageProps.value = {};
 
-		os.api('admin/server-info', {}).then(res => {
-			this.serverInfo = res;
+			nextTick(() => {
+				scroll(el.value, 0);
+			});
+		}, { immediate: true });
+
+		watch(() => props.initialPage, () => {
+			if (props.initialPage == null && !narrow.value) {
+				page.value = 'overview';
+			} else {
+				page.value = props.initialPage;
+				if (props.initialPage == null) {
+					INFO.value = indexInfo;
+				}
+			}
 		});
 
-		os.api('admin/get-table-stats', {}).then(res => {
-			this.dbInfo = Object.entries(res).sort((a, b) => b[1].size - a[1].size);
+		onMounted(() => {
+			narrow.value = el.value.offsetWidth < 800;
+			if (!narrow.value) {
+				page.value = 'overview';
+			}
 		});
-	},
 
-	methods: {
-		async showInstanceInfo(q) {
-			let instance = q;
-			if (typeof q === 'string') {
-				instance = await os.api('federation/show-instance', {
-					host: q
+		const invite = () => {
+			os.api('admin/invite').then(x => {
+				os.dialog({
+					type: 'info',
+					text: x.code
+				});
+			}).catch(e => {
+				os.dialog({
+					type: 'error',
+					text: e
 				});
-			}
-			os.popup(MkInstanceInfo, {
-				instance: instance
-			}, {}, 'closed');
-		},
-
-		fetchJobs() {
-			os.api('admin/queue/deliver-delayed', {}).then(jobs => {
-				this.jobs = jobs;
 			});
-		},
+		};
+
+		const lookup = (ev) => {
+			os.modalMenu([{
+				text: i18n.locale.user,
+				icon: 'fas fa-user',
+				action: () => {
+					lookupUser();
+				}
+			}, {
+				text: i18n.locale.note,
+				icon: 'fas fa-pencil-alt',
+				action: () => {
+					alert('TODO');
+				}
+			}, {
+				text: i18n.locale.file,
+				icon: 'fas fa-cloud',
+				action: () => {
+					alert('TODO');
+				}
+			}, {
+				text: i18n.locale.instance,
+				icon: 'fas fa-globe',
+				action: () => {
+					alert('TODO');
+				}
+			}], ev.currentTarget || ev.target);
+		};
 
-		fetchModLogs() {
-			os.api('admin/show-moderation-logs', {}).then(logs => {
-				this.modLogs = logs;
-			});
-		},
+		return {
+			[symbols.PAGE_INFO]: INFO,
+			page,
+			narrow,
+			view,
+			el,
+			onInfo,
+			pageProps,
+			component,
+			invite,
+			lookup,
+		};
+	},
+});
+</script>
 
-		bytes,
+<style lang="scss" scoped>
+.hiyeyicy {
+	&.wide {
+		display: flex;
+		max-width: 1100px;
+		margin: 0 auto;
+		height: 100%;
+
+		> .nav {
+			width: 32%;
+			box-sizing: border-box;
+			border-right: solid 0.5px var(--divider);
+			overflow: auto;
+		}
 
-		number,
+		> .main {
+			flex: 1;
+			min-width: 0;
+			overflow: auto;
+			--baseContentWidth: 100%;
+		}
 	}
-});
-</script>
+}
+
+.lxpfedzu {
+	padding: 16px;
+
+	> img {
+		display: block;
+		margin: auto;
+		height: 42px;
+		border-radius: 8px;
+	}
+}
+</style>
diff --git a/src/client/pages/instance/instance-block.vue b/src/client/pages/instance/instance-block.vue
new file mode 100644
index 0000000000..ed5740f339
--- /dev/null
+++ b/src/client/pages/instance/instance-block.vue
@@ -0,0 +1,71 @@
+<template>
+<FormBase>
+	<FormSuspense :p="init">
+		<FormTextarea v-model:value="blockedHosts">
+			<span>{{ $ts.blockedInstances }}</span>
+			<template #desc>{{ $ts.blockedInstancesDescription }}</template>
+		</FormTextarea>
+
+		<FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+	</FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSwitch from '@client/components/form/switch.vue';
+import FormInput from '@client/components/form/input.vue';
+import FormButton from '@client/components/form/button.vue';
+import FormBase from '@client/components/form/base.vue';
+import FormGroup from '@client/components/form/group.vue';
+import FormTextarea from '@client/components/form/textarea.vue';
+import FormInfo from '@client/components/form/info.vue';
+import FormSuspense from '@client/components/form/suspense.vue';
+import * as os from '@client/os';
+import * as symbols from '@client/symbols';
+import { fetchInstance } from '@client/instance';
+
+export default defineComponent({
+	components: {
+		FormSwitch,
+		FormInput,
+		FormBase,
+		FormGroup,
+		FormButton,
+		FormTextarea,
+		FormInfo,
+		FormSuspense,
+	},
+
+	emits: ['info'],
+
+	data() {
+		return {
+			[symbols.PAGE_INFO]: {
+				title: this.$ts.instanceBlocking,
+				icon: 'fas fa-ban'
+			},
+			blockedHosts: '',
+		}
+	},
+
+	async mounted() {
+		this.$emit('info', this[symbols.PAGE_INFO]);
+	},
+
+	methods: {
+		async init() {
+			const meta = await os.api('meta', { detail: true });
+			this.blockedHosts = meta.blockedHosts.join('\n');
+		},
+
+		save() {
+			os.apiWithDialog('admin/update-meta', {
+				blockedHosts: this.blockedHosts.split('\n') || [],
+			}).then(() => {
+				fetchInstance();
+			});
+		}
+	}
+});
+</script>
diff --git a/src/client/pages/instance/integrations-discord.vue b/src/client/pages/instance/integrations-discord.vue
new file mode 100644
index 0000000000..c7508918f8
--- /dev/null
+++ b/src/client/pages/instance/integrations-discord.vue
@@ -0,0 +1,85 @@
+<template>
+<FormBase>
+	<FormSuspense :p="init">
+		<FormSwitch v-model:value="enableDiscordIntegration">
+			{{ $ts.enable }}
+		</FormSwitch>
+
+		<template v-if="enableDiscordIntegration">
+			<FormInfo>Callback URL: {{ `${url}/api/dc/cb` }}</FormInfo>
+		
+			<FormInput v-model:value="discordClientId">
+				<template #prefix><i class="fas fa-key"></i></template>
+				Client ID
+			</FormInput>
+
+			<FormInput v-model:value="discordClientSecret">
+				<template #prefix><i class="fas fa-key"></i></template>
+				Client Secret
+			</FormInput>
+		</template>
+
+		<FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+	</FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSwitch from '@client/components/form/switch.vue';
+import FormInput from '@client/components/form/input.vue';
+import FormButton from '@client/components/form/button.vue';
+import FormBase from '@client/components/form/base.vue';
+import FormInfo from '@client/components/form/info.vue';
+import FormSuspense from '@client/components/form/suspense.vue';
+import * as os from '@client/os';
+import * as symbols from '@client/symbols';
+import { fetchInstance } from '@client/instance';
+
+export default defineComponent({
+	components: {
+		FormSwitch,
+		FormInput,
+		FormBase,
+		FormInfo,
+		FormButton,
+		FormSuspense,
+	},
+
+	emits: ['info'],
+
+	data() {
+		return {
+			[symbols.PAGE_INFO]: {
+				title: 'Discord',
+				icon: 'fab fa-discord'
+			},
+			enableDiscordIntegration: false,
+			discordClientId: null,
+			discordClientSecret: null,
+		}
+	},
+
+	async mounted() {
+		this.$emit('info', this[symbols.PAGE_INFO]);
+	},
+
+	methods: {
+		async init() {
+			const meta = await os.api('meta', { detail: true });
+			this.enableDiscordIntegration = meta.enableDiscordIntegration;
+			this.discordClientId = meta.discordClientId;
+			this.discordClientSecret = meta.discordClientSecret;
+		},
+		save() {
+			os.apiWithDialog('admin/update-meta', {
+				enableDiscordIntegration: this.enableDiscordIntegration,
+				discordClientId: this.discordClientId,
+				discordClientSecret: this.discordClientSecret,
+			}).then(() => {
+				fetchInstance();
+			});
+		}
+	}
+});
+</script>
diff --git a/src/client/pages/instance/integrations-github.vue b/src/client/pages/instance/integrations-github.vue
new file mode 100644
index 0000000000..16586b15b4
--- /dev/null
+++ b/src/client/pages/instance/integrations-github.vue
@@ -0,0 +1,85 @@
+<template>
+<FormBase>
+	<FormSuspense :p="init">
+		<FormSwitch v-model:value="enableGithubIntegration">
+			{{ $ts.enable }}
+		</FormSwitch>
+
+		<template v-if="enableGithubIntegration">
+			<FormInfo>Callback URL: {{ `${url}/api/gh/cb` }}</FormInfo>
+		
+			<FormInput v-model:value="githubClientId">
+				<template #prefix><i class="fas fa-key"></i></template>
+				Client ID
+			</FormInput>
+
+			<FormInput v-model:value="githubClientSecret">
+				<template #prefix><i class="fas fa-key"></i></template>
+				Client Secret
+			</FormInput>
+		</template>
+
+		<FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+	</FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSwitch from '@client/components/form/switch.vue';
+import FormInput from '@client/components/form/input.vue';
+import FormButton from '@client/components/form/button.vue';
+import FormBase from '@client/components/form/base.vue';
+import FormInfo from '@client/components/form/info.vue';
+import FormSuspense from '@client/components/form/suspense.vue';
+import * as os from '@client/os';
+import * as symbols from '@client/symbols';
+import { fetchInstance } from '@client/instance';
+
+export default defineComponent({
+	components: {
+		FormSwitch,
+		FormInput,
+		FormBase,
+		FormInfo,
+		FormButton,
+		FormSuspense,
+	},
+
+	emits: ['info'],
+
+	data() {
+		return {
+			[symbols.PAGE_INFO]: {
+				title: 'GitHub',
+				icon: 'fab fa-github'
+			},
+			enableGithubIntegration: false,
+			githubClientId: null,
+			githubClientSecret: null,
+		}
+	},
+
+	async mounted() {
+		this.$emit('info', this[symbols.PAGE_INFO]);
+	},
+
+	methods: {
+		async init() {
+			const meta = await os.api('meta', { detail: true });
+			this.enableGithubIntegration = meta.enableGithubIntegration;
+			this.githubClientId = meta.githubClientId;
+			this.githubClientSecret = meta.githubClientSecret;
+		},
+		save() {
+			os.apiWithDialog('admin/update-meta', {
+				enableGithubIntegration: this.enableGithubIntegration,
+				githubClientId: this.githubClientId,
+				githubClientSecret: this.githubClientSecret,
+			}).then(() => {
+				fetchInstance();
+			});
+		}
+	}
+});
+</script>
diff --git a/src/client/pages/instance/integrations-twitter.vue b/src/client/pages/instance/integrations-twitter.vue
new file mode 100644
index 0000000000..b08b7f40a5
--- /dev/null
+++ b/src/client/pages/instance/integrations-twitter.vue
@@ -0,0 +1,85 @@
+<template>
+<FormBase>
+	<FormSuspense :p="init">
+		<FormSwitch v-model:value="enableTwitterIntegration">
+			{{ $ts.enable }}
+		</FormSwitch>
+
+		<template v-if="enableTwitterIntegration">
+			<FormInfo>Callback URL: {{ `${url}/api/tw/cb` }}</FormInfo>
+		
+			<FormInput v-model:value="twitterConsumerKey">
+				<template #prefix><i class="fas fa-key"></i></template>
+				Consumer Key
+			</FormInput>
+
+			<FormInput v-model:value="twitterConsumerSecret">
+				<template #prefix><i class="fas fa-key"></i></template>
+				Consumer Secret
+			</FormInput>
+		</template>
+
+		<FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+	</FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSwitch from '@client/components/form/switch.vue';
+import FormInput from '@client/components/form/input.vue';
+import FormButton from '@client/components/form/button.vue';
+import FormBase from '@client/components/form/base.vue';
+import FormInfo from '@client/components/form/info.vue';
+import FormSuspense from '@client/components/form/suspense.vue';
+import * as os from '@client/os';
+import * as symbols from '@client/symbols';
+import { fetchInstance } from '@client/instance';
+
+export default defineComponent({
+	components: {
+		FormSwitch,
+		FormInput,
+		FormBase,
+		FormInfo,
+		FormButton,
+		FormSuspense,
+	},
+
+	emits: ['info'],
+
+	data() {
+		return {
+			[symbols.PAGE_INFO]: {
+				title: 'Twitter',
+				icon: 'fab fa-twitter'
+			},
+			enableTwitterIntegration: false,
+			twitterConsumerKey: null,
+			twitterConsumerSecret: null,
+		}
+	},
+
+	async mounted() {
+		this.$emit('info', this[symbols.PAGE_INFO]);
+	},
+
+	methods: {
+		async init() {
+			const meta = await os.api('meta', { detail: true });
+			this.enableTwitterIntegration = meta.enableTwitterIntegration;
+			this.twitterConsumerKey = meta.twitterConsumerKey;
+			this.twitterConsumerSecret = meta.twitterConsumerSecret;
+		},
+		save() {
+			os.apiWithDialog('admin/update-meta', {
+				enableTwitterIntegration: this.enableTwitterIntegration,
+				twitterConsumerKey: this.twitterConsumerKey,
+				twitterConsumerSecret: this.twitterConsumerSecret,
+			}).then(() => {
+				fetchInstance();
+			});
+		}
+	}
+});
+</script>
diff --git a/src/client/pages/instance/integrations.vue b/src/client/pages/instance/integrations.vue
new file mode 100644
index 0000000000..7debedc367
--- /dev/null
+++ b/src/client/pages/instance/integrations.vue
@@ -0,0 +1,73 @@
+<template>
+<FormBase>
+	<FormSuspense :p="init">
+		<FormLink to="/instance/integrations/twitter">
+			<i class="fab fa-twitter"></i> Twitter
+			<template #suffix>{{ enableTwitterIntegration ? $ts.enabled : $ts.disabled }}</template>
+		</FormLink>
+		<FormLink to="/instance/integrations/github">
+			<i class="fab fa-github"></i> GitHub
+			<template #suffix>{{ enableGithubIntegration ? $ts.enabled : $ts.disabled }}</template>
+		</FormLink>
+		<FormLink to="/instance/integrations/discord">
+			<i class="fab fa-discord"></i> Discord
+			<template #suffix>{{ enableDiscordIntegration ? $ts.enabled : $ts.disabled }}</template>
+		</FormLink>
+	</FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormLink from '@client/components/form/link.vue';
+import FormInput from '@client/components/form/input.vue';
+import FormButton from '@client/components/form/button.vue';
+import FormBase from '@client/components/form/base.vue';
+import FormGroup from '@client/components/form/group.vue';
+import FormTextarea from '@client/components/form/textarea.vue';
+import FormInfo from '@client/components/form/info.vue';
+import FormSuspense from '@client/components/form/suspense.vue';
+import * as os from '@client/os';
+import * as symbols from '@client/symbols';
+import { fetchInstance } from '@client/instance';
+
+export default defineComponent({
+	components: {
+		FormLink,
+		FormInput,
+		FormBase,
+		FormGroup,
+		FormButton,
+		FormTextarea,
+		FormInfo,
+		FormSuspense,
+	},
+
+	emits: ['info'],
+
+	data() {
+		return {
+			[symbols.PAGE_INFO]: {
+				title: this.$ts.integration,
+				icon: 'fas fa-share-alt'
+			},
+			enableTwitterIntegration: false,
+			enableGithubIntegration: false,
+			enableDiscordIntegration: false,
+		}
+	},
+
+	async mounted() {
+		this.$emit('info', this[symbols.PAGE_INFO]);
+	},
+
+	methods: {
+		async init() {
+			const meta = await os.api('meta', { detail: true });
+			this.enableTwitterIntegration = meta.enableTwitterIntegration;
+			this.enableGithubIntegration = meta.enableGithubIntegration;
+			this.enableDiscordIntegration = meta.enableDiscordIntegration;
+		},
+	}
+});
+</script>
diff --git a/src/client/pages/instance/metrics.vue b/src/client/pages/instance/metrics.vue
new file mode 100644
index 0000000000..18cfe5eee2
--- /dev/null
+++ b/src/client/pages/instance/metrics.vue
@@ -0,0 +1,455 @@
+<template>
+<div class="_formItem">
+	<div class="_formLabel"><i class="fas fa-microchip"></i> {{ $ts.cpuAndMemory }}</div>
+	<div class="_formPanel xhexznfu">
+		<div>
+			<canvas :ref="cpumem"></canvas>
+		</div>
+		<div v-if="serverInfo">
+			<div class="_table">
+				<div class="_row">
+					<div class="_cell"><div class="_label">MEM total</div>{{ bytes(serverInfo.mem.total) }}</div>
+					<div class="_cell"><div class="_label">MEM used</div>{{ bytes(memUsage) }} ({{ (memUsage / serverInfo.mem.total * 100).toFixed(0) }}%)</div>
+					<div class="_cell"><div class="_label">MEM free</div>{{ bytes(serverInfo.mem.total - memUsage) }} ({{ ((serverInfo.mem.total - memUsage) / serverInfo.mem.total * 100).toFixed(0) }}%)</div>
+				</div>
+			</div>
+		</div>
+	</div>
+</div>
+<div class="_formItem">
+	<div class="_formLabel"><i class="fas fa-hdd"></i> {{ $ts.disk }}</div>
+	<div class="_formPanel xhexznfu">
+		<div>
+			<canvas :ref="disk"></canvas>
+		</div>
+		<div v-if="serverInfo">
+			<div class="_table">
+				<div class="_row">
+					<div class="_cell"><div class="_label">Disk total</div>{{ bytes(serverInfo.fs.total) }}</div>
+					<div class="_cell"><div class="_label">Disk used</div>{{ bytes(serverInfo.fs.used) }} ({{ (serverInfo.fs.used / serverInfo.fs.total * 100).toFixed(0) }}%)</div>
+					<div class="_cell"><div class="_label">Disk free</div>{{ bytes(serverInfo.fs.total - serverInfo.fs.used) }} ({{ ((serverInfo.fs.total - serverInfo.fs.used) / serverInfo.fs.total * 100).toFixed(0) }}%)</div>
+				</div>
+			</div>
+		</div>
+	</div>
+</div>
+<div class="_formItem">
+	<div class="_formLabel"><i class="fas fa-exchange-alt"></i> {{ $ts.network }}</div>
+	<div class="_formPanel xhexznfu">
+		<div>
+			<canvas :ref="net"></canvas>
+		</div>
+		<div v-if="serverInfo">
+			<div class="_table">
+				<div class="_row">
+					<div class="_cell"><div class="_label">Interface</div>{{ serverInfo.net.interface }}</div>
+				</div>
+			</div>
+		</div>
+	</div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import Chart from 'chart.js';
+import MkButton from '@client/components/ui/button.vue';
+import MkSelect from '@client/components/ui/select.vue';
+import MkInput from '@client/components/ui/input.vue';
+import MkContainer from '@client/components/ui/container.vue';
+import MkFolder from '@client/components/ui/folder.vue';
+import MkwFederation from '../../widgets/federation.vue';
+import { version, url } from '@client/config';
+import bytes from '../../filters/bytes';
+import number from '../../filters/number';
+import MkInstanceInfo from './instance.vue';
+
+const alpha = (hex, a) => {
+	const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
+	const r = parseInt(result[1], 16);
+	const g = parseInt(result[2], 16);
+	const b = parseInt(result[3], 16);
+	return `rgba(${r}, ${g}, ${b}, ${a})`;
+};
+import * as os from '@client/os';
+
+export default defineComponent({
+	components: {
+		MkButton,
+		MkSelect,
+		MkInput,
+		MkContainer,
+		MkFolder,
+		MkwFederation,
+	},
+
+	data() {
+		return {
+			version,
+			url,
+			stats: null,
+			serverInfo: null,
+			connection: null,
+			queueConnection: os.stream.useSharedConnection('queueStats'),
+			memUsage: 0,
+			chartCpuMem: null,
+			chartNet: null,
+			jobs: [],
+			logs: [],
+			logLevel: 'all',
+			logDomain: '',
+			modLogs: [],
+			dbInfo: null,
+			overviewHeight: '1fr',
+			queueHeight: '1fr',
+			paused: false,
+		}
+	},
+
+	computed: {
+		gridColor() {
+			// TODO: var(--panel)の色が暗いか明るいかで判定する
+			return this.$store.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
+		},
+	},
+
+	mounted() {
+		this.fetchJobs();
+
+		Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+
+		os.api('admin/server-info', {}).then(res => {
+			this.serverInfo = res;
+
+			this.connection = os.stream.useSharedConnection('serverStats');
+			this.connection.on('stats', this.onStats);
+			this.connection.on('statsLog', this.onStatsLog);
+			this.connection.send('requestLog', {
+				id: Math.random().toString().substr(2, 8),
+				length: 150
+			});
+
+			this.$nextTick(() => {
+				this.queueConnection.send('requestLog', {
+					id: Math.random().toString().substr(2, 8),
+					length: 200
+				});
+			});
+		});
+	},
+
+	beforeUnmount() {
+		if (this.connection) {
+			this.connection.off('stats', this.onStats);
+			this.connection.off('statsLog', this.onStatsLog);
+			this.connection.dispose();
+		}
+		this.queueConnection.dispose();
+	},
+
+	methods: {
+		cpumem(el) {
+			if (this.chartCpuMem != null) return;
+			this.chartCpuMem = markRaw(new Chart(el, {
+				type: 'line',
+				data: {
+					labels: [],
+					datasets: [{
+						label: 'CPU',
+						pointRadius: 0,
+						lineTension: 0,
+						borderWidth: 2,
+						borderColor: '#86b300',
+						backgroundColor: alpha('#86b300', 0.1),
+						data: []
+					}, {
+						label: 'MEM (active)',
+						pointRadius: 0,
+						lineTension: 0,
+						borderWidth: 2,
+						borderColor: '#935dbf',
+						backgroundColor: alpha('#935dbf', 0.02),
+						data: []
+					}, {
+						label: 'MEM (used)',
+						pointRadius: 0,
+						lineTension: 0,
+						borderWidth: 2,
+						borderColor: '#935dbf',
+						borderDash: [5, 5],
+						fill: false,
+						data: []
+					}]
+				},
+				options: {
+					aspectRatio: 3,
+					layout: {
+						padding: {
+							left: 16,
+							right: 16,
+							top: 16,
+							bottom: 0
+						}
+					},
+					legend: {
+						position: 'bottom',
+						labels: {
+							boxWidth: 16,
+						}
+					},
+					scales: {
+						xAxes: [{
+							gridLines: {
+								display: false,
+								color: this.gridColor,
+								zeroLineColor: this.gridColor,
+							},
+							ticks: {
+								display: false,
+							}
+						}],
+						yAxes: [{
+							position: 'right',
+							gridLines: {
+								display: true,
+								color: this.gridColor,
+								zeroLineColor: this.gridColor,
+							},
+							ticks: {
+								display: false,
+								max: 100
+							}
+						}]
+					},
+					tooltips: {
+						intersect: false,
+						mode: 'index',
+					}
+				}
+			}));
+		},
+
+		net(el) {
+			if (this.chartNet != null) return;
+			this.chartNet = markRaw(new Chart(el, {
+				type: 'line',
+				data: {
+					labels: [],
+					datasets: [{
+						label: 'In',
+						pointRadius: 0,
+						lineTension: 0,
+						borderWidth: 2,
+						borderColor: '#94a029',
+						backgroundColor: alpha('#94a029', 0.1),
+						data: []
+					}, {
+						label: 'Out',
+						pointRadius: 0,
+						lineTension: 0,
+						borderWidth: 2,
+						borderColor: '#ff9156',
+						backgroundColor: alpha('#ff9156', 0.1),
+						data: []
+					}]
+				},
+				options: {
+					aspectRatio: 3,
+					layout: {
+						padding: {
+							left: 16,
+							right: 16,
+							top: 16,
+							bottom: 0
+						}
+					},
+					legend: {
+						position: 'bottom',
+						labels: {
+							boxWidth: 16,
+						}
+					},
+					scales: {
+						xAxes: [{
+							gridLines: {
+								display: false,
+								color: this.gridColor,
+								zeroLineColor: this.gridColor,
+							},
+							ticks: {
+								display: false
+							}
+						}],
+						yAxes: [{
+							position: 'right',
+							gridLines: {
+								display: true,
+								color: this.gridColor,
+								zeroLineColor: this.gridColor,
+							},
+							ticks: {
+								display: false,
+							}
+						}]
+					},
+					tooltips: {
+						intersect: false,
+						mode: 'index',
+					}
+				}
+			}));
+		},
+
+		disk(el) {
+			if (this.chartDisk != null) return;
+			this.chartDisk = markRaw(new Chart(el, {
+				type: 'line',
+				data: {
+					labels: [],
+					datasets: [{
+						label: 'Read',
+						pointRadius: 0,
+						lineTension: 0,
+						borderWidth: 2,
+						borderColor: '#94a029',
+						backgroundColor: alpha('#94a029', 0.1),
+						data: []
+					}, {
+						label: 'Write',
+						pointRadius: 0,
+						lineTension: 0,
+						borderWidth: 2,
+						borderColor: '#ff9156',
+						backgroundColor: alpha('#ff9156', 0.1),
+						data: []
+					}]
+				},
+				options: {
+					aspectRatio: 3,
+					layout: {
+						padding: {
+							left: 16,
+							right: 16,
+							top: 16,
+							bottom: 0
+						}
+					},
+					legend: {
+						position: 'bottom',
+						labels: {
+							boxWidth: 16,
+						}
+					},
+					scales: {
+						xAxes: [{
+							gridLines: {
+								display: false,
+								color: this.gridColor,
+								zeroLineColor: this.gridColor,
+							},
+							ticks: {
+								display: false
+							}
+						}],
+						yAxes: [{
+							position: 'right',
+							gridLines: {
+								display: true,
+								color: this.gridColor,
+								zeroLineColor: this.gridColor,
+							},
+							ticks: {
+								display: false,
+							}
+						}]
+					},
+					tooltips: {
+						intersect: false,
+						mode: 'index',
+					}
+				}
+			}));
+		},
+
+		async showInstanceInfo(q) {
+			let instance = q;
+			if (typeof q === 'string') {
+				instance = await os.api('federation/show-instance', {
+					host: q
+				});
+			}
+			os.popup(MkInstanceInfo, {
+				instance: instance
+			}, {}, 'closed');
+		},
+
+		fetchJobs() {
+			os.api('admin/queue/deliver-delayed', {}).then(jobs => {
+				this.jobs = jobs;
+			});
+		},
+
+		onStats(stats) {
+			if (this.paused) return;
+
+			const cpu = (stats.cpu * 100).toFixed(0);
+			const memActive = (stats.mem.active / this.serverInfo.mem.total * 100).toFixed(0);
+			const memUsed = (stats.mem.used / this.serverInfo.mem.total * 100).toFixed(0);
+			this.memUsage = stats.mem.active;
+
+			this.chartCpuMem.data.labels.push('');
+			this.chartCpuMem.data.datasets[0].data.push(cpu);
+			this.chartCpuMem.data.datasets[1].data.push(memActive);
+			this.chartCpuMem.data.datasets[2].data.push(memUsed);
+			this.chartNet.data.labels.push('');
+			this.chartNet.data.datasets[0].data.push(stats.net.rx);
+			this.chartNet.data.datasets[1].data.push(stats.net.tx);
+			this.chartDisk.data.labels.push('');
+			this.chartDisk.data.datasets[0].data.push(stats.fs.r);
+			this.chartDisk.data.datasets[1].data.push(stats.fs.w);
+			if (this.chartCpuMem.data.datasets[0].data.length > 150) {
+				this.chartCpuMem.data.labels.shift();
+				this.chartCpuMem.data.datasets[0].data.shift();
+				this.chartCpuMem.data.datasets[1].data.shift();
+				this.chartCpuMem.data.datasets[2].data.shift();
+				this.chartNet.data.labels.shift();
+				this.chartNet.data.datasets[0].data.shift();
+				this.chartNet.data.datasets[1].data.shift();
+				this.chartDisk.data.labels.shift();
+				this.chartDisk.data.datasets[0].data.shift();
+				this.chartDisk.data.datasets[1].data.shift();
+			}
+			this.chartCpuMem.update();
+			this.chartNet.update();
+			this.chartDisk.update();
+		},
+
+		onStatsLog(statsLog) {
+			for (const stats of [...statsLog].reverse()) {
+				this.onStats(stats);
+			}
+		},
+
+		bytes,
+
+		number,
+
+		pause() {
+			this.paused = true;
+		},
+
+		resume() {
+			this.paused = false;
+		},
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.xhexznfu {
+	> div:nth-child(2) {
+		padding: 16px;
+		border-top: solid 0.5px var(--divider);
+	}
+}
+</style>
diff --git a/src/client/pages/instance/object-storage.vue b/src/client/pages/instance/object-storage.vue
new file mode 100644
index 0000000000..814aeb6e48
--- /dev/null
+++ b/src/client/pages/instance/object-storage.vue
@@ -0,0 +1,154 @@
+<template>
+<FormBase>
+	<FormSuspense :p="init">
+		<FormSwitch v-model:value="useObjectStorage">{{ $ts.useObjectStorage }}</FormSwitch>
+
+		<template v-if="useObjectStorage">
+			<FormInput v-model:value="objectStorageBaseUrl">
+				<span>{{ $ts.objectStorageBaseUrl }}</span>
+				<template #desc>{{ $ts.objectStorageBaseUrlDesc }}</template>
+			</FormInput>
+
+			<FormInput v-model:value="objectStorageBucket">
+				<span>{{ $ts.objectStorageBucket }}</span>
+				<template #desc>{{ $ts.objectStorageBucketDesc }}</template>
+			</FormInput>
+
+			<FormInput v-model:value="objectStoragePrefix">
+				<span>{{ $ts.objectStoragePrefix }}</span>
+				<template #desc>{{ $ts.objectStoragePrefixDesc }}</template>
+			</FormInput>
+
+			<FormInput v-model:value="objectStorageEndpoint">
+				<span>{{ $ts.objectStorageEndpoint }}</span>
+				<template #desc>{{ $ts.objectStorageEndpointDesc }}</template>
+			</FormInput>
+
+			<FormInput v-model:value="objectStorageRegion">
+				<span>{{ $ts.objectStorageRegion }}</span>
+				<template #desc>{{ $ts.objectStorageRegionDesc }}</template>
+			</FormInput>
+
+			<FormInput v-model:value="objectStorageAccessKey">
+				<template #prefix><i class="fas fa-key"></i></template>
+				<span>Access key</span>
+			</FormInput>
+
+			<FormInput v-model:value="objectStorageSecretKey">
+				<template #prefix><i class="fas fa-key"></i></template>
+				<span>Secret key</span>
+			</FormInput>
+
+			<FormSwitch v-model:value="objectStorageUseSSL">
+				{{ $ts.objectStorageUseSSL }}
+				<template #desc>{{ $ts.objectStorageUseSSLDesc }}</template>
+			</FormSwitch>
+
+			<FormSwitch v-model:value="objectStorageUseProxy">
+				{{ $ts.objectStorageUseProxy }}
+				<template #desc>{{ $ts.objectStorageUseProxyDesc }}</template>
+			</FormSwitch>
+
+			<FormSwitch v-model:value="objectStorageSetPublicRead">
+				{{ $ts.objectStorageSetPublicRead }}
+			</FormSwitch>
+
+			<FormSwitch v-model:value="objectStorageS3ForcePathStyle">
+				s3ForcePathStyle
+			</FormSwitch>
+		</template>
+
+		<FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+	</FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSwitch from '@client/components/form/switch.vue';
+import FormInput from '@client/components/form/input.vue';
+import FormButton from '@client/components/form/button.vue';
+import FormBase from '@client/components/form/base.vue';
+import FormGroup from '@client/components/form/group.vue';
+import FormSuspense from '@client/components/form/suspense.vue';
+import * as os from '@client/os';
+import * as symbols from '@client/symbols';
+import { fetchInstance } from '@client/instance';
+
+export default defineComponent({
+	components: {
+		FormSwitch,
+		FormInput,
+		FormBase,
+		FormGroup,
+		FormButton,
+		FormSuspense,
+	},
+
+	emits: ['info'],
+
+	data() {
+		return {
+			[symbols.PAGE_INFO]: {
+				title: this.$ts.objectStorage,
+				icon: 'fas fa-cloud'
+			},
+			useObjectStorage: false,
+			objectStorageBaseUrl: null,
+			objectStorageBucket: null,
+			objectStoragePrefix: null,
+			objectStorageEndpoint: null,
+			objectStorageRegion: null,
+			objectStoragePort: null,
+			objectStorageAccessKey: null,
+			objectStorageSecretKey: null,
+			objectStorageUseSSL: false,
+			objectStorageUseProxy: false,
+			objectStorageSetPublicRead: false,
+			objectStorageS3ForcePathStyle: true,
+		}
+	},
+
+	async mounted() {
+		this.$emit('info', this[symbols.PAGE_INFO]);
+	},
+
+	methods: {
+		async init() {
+			const meta = await os.api('meta', { detail: true });
+			this.useObjectStorage = meta.useObjectStorage;
+			this.objectStorageBaseUrl = meta.objectStorageBaseUrl;
+			this.objectStorageBucket = meta.objectStorageBucket;
+			this.objectStoragePrefix = meta.objectStoragePrefix;
+			this.objectStorageEndpoint = meta.objectStorageEndpoint;
+			this.objectStorageRegion = meta.objectStorageRegion;
+			this.objectStoragePort = meta.objectStoragePort;
+			this.objectStorageAccessKey = meta.objectStorageAccessKey;
+			this.objectStorageSecretKey = meta.objectStorageSecretKey;
+			this.objectStorageUseSSL = meta.objectStorageUseSSL;
+			this.objectStorageUseProxy = meta.objectStorageUseProxy;
+			this.objectStorageSetPublicRead = meta.objectStorageSetPublicRead;
+			this.objectStorageS3ForcePathStyle = meta.objectStorageS3ForcePathStyle;
+		},
+		save() {
+			os.apiWithDialog('admin/update-meta', {
+				useObjectStorage: this.useObjectStorage,
+				objectStorageBaseUrl: this.objectStorageBaseUrl ? this.objectStorageBaseUrl : null,
+				objectStorageBucket: this.objectStorageBucket ? this.objectStorageBucket : null,
+				objectStoragePrefix: this.objectStoragePrefix ? this.objectStoragePrefix : null,
+				objectStorageEndpoint: this.objectStorageEndpoint ? this.objectStorageEndpoint : null,
+				objectStorageRegion: this.objectStorageRegion ? this.objectStorageRegion : null,
+				objectStoragePort: this.objectStoragePort ? this.objectStoragePort : null,
+				objectStorageAccessKey: this.objectStorageAccessKey ? this.objectStorageAccessKey : null,
+				objectStorageSecretKey: this.objectStorageSecretKey ? this.objectStorageSecretKey : null,
+				objectStorageUseSSL: this.objectStorageUseSSL,
+				objectStorageUseProxy: this.objectStorageUseProxy,
+				objectStorageSetPublicRead: this.objectStorageSetPublicRead,
+				objectStorageS3ForcePathStyle: this.objectStorageS3ForcePathStyle,
+			}).then(() => {
+				fetchInstance();
+			});
+		}
+	}
+});
+</script>
diff --git a/src/client/pages/instance/other-settings.vue b/src/client/pages/instance/other-settings.vue
new file mode 100644
index 0000000000..b3954149a8
--- /dev/null
+++ b/src/client/pages/instance/other-settings.vue
@@ -0,0 +1,68 @@
+<template>
+<FormBase>
+	<FormSuspense :p="init">
+		<FormGroup>
+			<FormInput v-model:value="summalyProxy">
+				<template #prefix><i class="fas fa-link"></i></template>
+				Summaly Proxy URL
+			</FormInput>
+		</FormGroup>
+
+		<FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+	</FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSwitch from '@client/components/form/switch.vue';
+import FormInput from '@client/components/form/input.vue';
+import FormButton from '@client/components/form/button.vue';
+import FormBase from '@client/components/form/base.vue';
+import FormGroup from '@client/components/form/group.vue';
+import FormSuspense from '@client/components/form/suspense.vue';
+import * as os from '@client/os';
+import * as symbols from '@client/symbols';
+import { fetchInstance } from '@client/instance';
+
+export default defineComponent({
+	components: {
+		FormSwitch,
+		FormInput,
+		FormBase,
+		FormGroup,
+		FormButton,
+		FormSuspense,
+	},
+
+	emits: ['info'],
+
+	data() {
+		return {
+			[symbols.PAGE_INFO]: {
+				title: this.$ts.other,
+				icon: 'fas fa-cogs'
+			},
+			summalyProxy: '',
+		}
+	},
+
+	async mounted() {
+		this.$emit('info', this[symbols.PAGE_INFO]);
+	},
+
+	methods: {
+		async init() {
+			const meta = await os.api('meta', { detail: true });
+			this.summalyProxy = meta.summalyProxy;
+		},
+		save() {
+			os.apiWithDialog('admin/update-meta', {
+				summalyProxy: this.summalyProxy,
+			}).then(() => {
+				fetchInstance();
+			});
+		}
+	}
+});
+</script>
diff --git a/src/client/pages/instance/overview.vue b/src/client/pages/instance/overview.vue
new file mode 100644
index 0000000000..651ace08f9
--- /dev/null
+++ b/src/client/pages/instance/overview.vue
@@ -0,0 +1,131 @@
+<template>
+<FormBase>
+	<FormSuspense :p="init">
+		<FormSuspense :p="fetchStats" v-slot="{ result: stats }">
+			<FormGroup>
+				<FormKeyValueView>
+					<template #key>Users</template>
+					<template #value>{{ number(stats.originalUsersCount) }}</template>
+				</FormKeyValueView>
+				<FormKeyValueView>
+					<template #key>Notes</template>
+					<template #value>{{ number(stats.originalNotesCount) }}</template>
+				</FormKeyValueView>
+			</FormGroup>
+		</FormSuspense>
+	
+		<div class="_formItem">
+			<div class="_formPanel">
+				<MkInstanceStats :chart-limit="300" :detailed="true"/>
+			</div>
+		</div>
+
+		<XMetrics/>
+
+		<FormSuspense :p="fetchServerInfo" v-slot="{ result: serverInfo }">
+			<FormGroup>
+				<FormKeyValueView>
+					<template #key>Node.js</template>
+					<template #value>{{ serverInfo.node }}</template>
+				</FormKeyValueView>
+				<FormKeyValueView>
+					<template #key>PostgreSQL</template>
+					<template #value>{{ serverInfo.psql }}</template>
+				</FormKeyValueView>
+				<FormKeyValueView>
+					<template #key>Redis</template>
+					<template #value>{{ serverInfo.redis }}</template>
+				</FormKeyValueView>
+			</FormGroup>
+		</FormSuspense>
+	</FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, markRaw } from 'vue';
+import VueJsonPretty from 'vue-json-pretty';
+import FormKeyValueView from '@client/components/form/key-value-view.vue';
+import FormInput from '@client/components/form/input.vue';
+import FormButton from '@client/components/form/button.vue';
+import FormBase from '@client/components/form/base.vue';
+import FormGroup from '@client/components/form/group.vue';
+import FormTextarea from '@client/components/form/textarea.vue';
+import FormInfo from '@client/components/form/info.vue';
+import FormSuspense from '@client/components/form/suspense.vue';
+import MkInstanceStats from '@client/components/instance-stats.vue';
+import MkButton from '@client/components/ui/button.vue';
+import MkSelect from '@client/components/ui/select.vue';
+import MkInput from '@client/components/ui/input.vue';
+import MkContainer from '@client/components/ui/container.vue';
+import MkFolder from '@client/components/ui/folder.vue';
+import { version, url } from '@client/config';
+import bytes from '../../filters/bytes';
+import number from '../../filters/number';
+import MkInstanceInfo from './instance.vue';
+import XMetrics from './metrics.vue';
+import * as os from '@client/os';
+import * as symbols from '@client/symbols';
+
+export default defineComponent({
+	components: {
+		FormBase,
+		FormSuspense,
+		FormGroup,
+		FormKeyValueView,
+		MkInstanceStats,
+		MkButton,
+		MkSelect,
+		MkInput,
+		MkContainer,
+		MkFolder,
+		XMetrics,
+		VueJsonPretty,
+	},
+
+	emits: ['info'],
+
+	data() {
+		return {
+			[symbols.PAGE_INFO]: {
+				title: this.$ts.overview,
+				icon: 'fas fa-tachometer-alt'
+			},
+			page: 'index',
+			version,
+			url,
+			stats: null,
+			fetchStats: () => os.api('stats', {}),
+			fetchServerInfo: () => os.api('admin/server-info', {}),
+			fetchJobs: () => os.api('admin/queue/deliver-delayed', {}),
+			fetchModLogs: () => os.api('admin/show-moderation-logs', {}),
+		}
+	},
+
+	async mounted() {
+		this.$emit('info', this[symbols.PAGE_INFO]);
+	},
+
+	methods: {
+		async init() {
+			this.meta = await os.api('meta', { detail: true });
+		},
+	
+		async showInstanceInfo(q) {
+			let instance = q;
+			if (typeof q === 'string') {
+				instance = await os.api('federation/show-instance', {
+					host: q
+				});
+			}
+			os.popup(MkInstanceInfo, {
+				instance: instance
+			}, {}, 'closed');
+		},
+
+		bytes,
+
+		number,
+	}
+});
+</script>
diff --git a/src/client/pages/instance/proxy-account.vue b/src/client/pages/instance/proxy-account.vue
new file mode 100644
index 0000000000..3e2df8dcb4
--- /dev/null
+++ b/src/client/pages/instance/proxy-account.vue
@@ -0,0 +1,86 @@
+<template>
+<FormBase>
+	<FormSuspense :p="init">
+		<FormGroup>
+			<FormKeyValueView>
+				<template #key>{{ $ts.proxyAccount }}</template>
+				<template #value>{{ proxyAccount ? `@${proxyAccount.username}` : $ts.none }}</template>
+			</FormKeyValueView>
+			<template #caption>{{ $ts.proxyAccountDescription }}</template>
+		</FormGroup>
+
+		<FormButton @click="chooseProxyAccount" primary>{{ $ts.selectAccount }}</FormButton>
+	</FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormKeyValueView from '@client/components/form/key-value-view.vue';
+import FormInput from '@client/components/form/input.vue';
+import FormButton from '@client/components/form/button.vue';
+import FormBase from '@client/components/form/base.vue';
+import FormGroup from '@client/components/form/group.vue';
+import FormTextarea from '@client/components/form/textarea.vue';
+import FormInfo from '@client/components/form/info.vue';
+import FormSuspense from '@client/components/form/suspense.vue';
+import * as os from '@client/os';
+import * as symbols from '@client/symbols';
+import { fetchInstance } from '@client/instance';
+
+export default defineComponent({
+	components: {
+		FormKeyValueView,
+		FormInput,
+		FormBase,
+		FormGroup,
+		FormButton,
+		FormTextarea,
+		FormInfo,
+		FormSuspense,
+	},
+
+	emits: ['info'],
+
+	data() {
+		return {
+			[symbols.PAGE_INFO]: {
+				title: this.$ts.proxyAccount,
+				icon: 'fas fa-ghost'
+			},
+			proxyAccount: null,
+			proxyAccountId: null,
+		}
+	},
+
+	async mounted() {
+		this.$emit('info', this[symbols.PAGE_INFO]);
+	},
+
+	methods: {
+		async init() {
+			const meta = await os.api('meta', { detail: true });
+			this.proxyAccountId = meta.proxyAccountId;
+			if (this.proxyAccountId) {
+				this.proxyAccount = await os.api('users/show', { userId: this.proxyAccountId });
+			}
+		},
+
+		chooseProxyAccount() {
+			os.selectUser().then(user => {
+				this.proxyAccount = user;
+				this.proxyAccountId = user.id;
+				this.save();
+			});
+		},
+
+		save() {
+			os.apiWithDialog('admin/update-meta', {
+				proxyAccountId: this.proxyAccountId,
+			}).then(() => {
+				fetchInstance();
+			});
+		}
+	}
+});
+</script>
diff --git a/src/client/pages/instance/queue.chart.vue b/src/client/pages/instance/queue.chart.vue
index 0eb70debfb..446c979209 100644
--- a/src/client/pages/instance/queue.chart.vue
+++ b/src/client/pages/instance/queue.chart.vue
@@ -1,27 +1,29 @@
 <template>
-<section class="_section">
-	<div class="_title"><slot name="title"></slot></div>
-	<div class="_content _table">
-		<div class="_row">
-			<div class="_cell"><div class="_label">Process</div>{{ number(activeSincePrevTick) }}</div>
-			<div class="_cell"><div class="_label">Active</div>{{ number(active) }}</div>
-			<div class="_cell"><div class="_label">Waiting</div>{{ number(waiting) }}</div>
-			<div class="_cell"><div class="_label">Delayed</div>{{ number(delayed) }}</div>
+<div class="_formItem">
+	<div class="_formLabel"><slot name="title"></slot></div>
+	<div class="_formPanel pumxzjhg">
+		<div class="_table status">
+			<div class="_row">
+				<div class="_cell"><div class="_label">Process</div>{{ number(activeSincePrevTick) }}</div>
+				<div class="_cell"><div class="_label">Active</div>{{ number(active) }}</div>
+				<div class="_cell"><div class="_label">Waiting</div>{{ number(waiting) }}</div>
+				<div class="_cell"><div class="_label">Delayed</div>{{ number(delayed) }}</div>
+			</div>
 		</div>
-	</div>
-	<div class="_content" style="margin-bottom: -8px;">
-		<canvas ref="chart"></canvas>
-	</div>
-	<div class="_content" style="max-height: 180px; overflow: auto;">
-		<div v-if="jobs.length > 0">
-			<div v-for="job in jobs" :key="job[0]">
-				<span>{{ job[0] }}</span>
-				<span style="margin-left: 8px; opacity: 0.7;">({{ number(job[1]) }} jobs)</span>
+		<div class="">
+			<canvas ref="chart"></canvas>
+		</div>
+		<div class="jobs">
+			<div v-if="jobs.length > 0">
+				<div v-for="job in jobs" :key="job[0]">
+					<span>{{ job[0] }}</span>
+					<span style="margin-left: 8px; opacity: 0.7;">({{ number(job[1]) }} jobs)</span>
+				</div>
 			</div>
+			<span v-else style="opacity: 0.5;">{{ $ts.noJobs }}</span>
 		</div>
-		<span v-else style="opacity: 0.5;">{{ $ts.noJobs }}</span>
 	</div>
-</section>
+</div>
 </template>
 
 <script lang="ts">
@@ -110,10 +112,10 @@ export default defineComponent({
 				aspectRatio: 3,
 				layout: {
 					padding: {
-						left: 0,
-						right: 0,
-						top: 8,
-						bottom: 0
+						left: 16,
+						right: 16,
+						top: 16,
+						bottom: 12
 					}
 				},
 				legend: {
@@ -198,3 +200,19 @@ export default defineComponent({
 	}
 });
 </script>
+
+<style lang="scss" scoped>
+.pumxzjhg {
+	> .status {
+		padding: 16px;
+		border-bottom: solid 0.5px var(--divider);
+	}
+
+	> .jobs {
+		padding: 16px;
+		border-top: solid 0.5px var(--divider);
+		max-height: 180px;
+		overflow: auto;
+	}
+}
+</style>
diff --git a/src/client/pages/instance/queue.vue b/src/client/pages/instance/queue.vue
index 0c1e0e51b5..2dccf48d31 100644
--- a/src/client/pages/instance/queue.vue
+++ b/src/client/pages/instance/queue.vue
@@ -1,43 +1,47 @@
 <template>
-<div>
+<FormBase>
 	<XQueue :connection="connection" domain="inbox">
-		<template #title><i class="fas fa-exchange-alt"></i> In</template>
+		<template #title>In</template>
 	</XQueue>
 	<XQueue :connection="connection" domain="deliver">
-		<template #title><i class="fas fa-exchange-alt"></i> Out</template>
+		<template #title>Out</template>
 	</XQueue>
-	<section class="_section">
-		<div class="_content">
-			<MkButton @click="clear()"><i class="fas fa-trash-alt"></i> {{ $ts.clearQueue }}</MkButton>
-		</div>
-	</section>
-</div>
+	<FormButton @click="clear()" danger><i class="fas fa-trash-alt"></i> {{ $ts.clearQueue }}</FormButton>
+</FormBase>
 </template>
 
 <script lang="ts">
 import { defineComponent } from 'vue';
 import MkButton from '@client/components/ui/button.vue';
 import XQueue from './queue.chart.vue';
+import FormBase from '@client/components/form/base.vue';
+import FormButton from '@client/components/form/button.vue';
 import * as os from '@client/os';
 import * as symbols from '@client/symbols';
 
 export default defineComponent({
 	components: {
+		FormBase,
+		FormButton,
 		MkButton,
 		XQueue,
 	},
 
+	emits: ['info'],
+
 	data() {
 		return {
 			[symbols.PAGE_INFO]: {
 				title: this.$ts.jobQueue,
-				icon: 'fas fa-exchange-alt',
+				icon: 'fas fa-clipboard-list',
 			},
 			connection: os.stream.useSharedConnection('queueStats'),
 		}
 	},
 
 	mounted() {
+		this.$emit('info', this[symbols.PAGE_INFO]);
+
 		this.$nextTick(() => {
 			this.connection.send('requestLog', {
 				id: Math.random().toString().substr(2, 8),
diff --git a/src/client/pages/instance/relays.vue b/src/client/pages/instance/relays.vue
index dbf75ea53b..a3e4e7d1da 100644
--- a/src/client/pages/instance/relays.vue
+++ b/src/client/pages/instance/relays.vue
@@ -1,44 +1,41 @@
 <template>
-<div class="relaycxt">
-	<section class="_section add">
-		<div class="_title"><i class="fas fa-plus"></i> {{ $ts.addRelay }}</div>
-		<div class="_content">
-			<MkInput v-model:value="inbox">
-				<span>{{ $ts.inboxUrl }}</span>
-			</MkInput>
-			<MkButton @click="add(inbox)" primary><i class="fas fa-plus"></i> {{ $ts.add }}</MkButton>
-		</div>
-	</section>
+<FormBase class="relaycxt">
+	<FormButton @click="addRelay" primary><i class="fas fa-plus"></i> {{ $ts.addRelay }}</FormButton>
 
-	<section class="_section relays">
-		<div class="_title"><i class="fas fa-project-diagram"></i> {{ $ts.addedRelays }}</div>
-		<div class="_content relay" v-for="relay in relays" :key="relay.inbox">
+	<div class="_formItem" v-for="relay in relays" :key="relay.inbox">
+		<div class="_formPanel" style="padding: 16px;">
 			<div>{{ relay.inbox }}</div>
 			<div>{{ $t(`_relayStatus.${relay.status}`) }}</div>
-			<MkButton class="button" inline @click="remove(relay.inbox)"><i class="fas fa-trash-alt"></i> {{ $ts.remove }}</MkButton>
+			<MkButton class="button" inline danger @click="remove(relay.inbox)"><i class="fas fa-trash-alt"></i> {{ $ts.remove }}</MkButton>
 		</div>
-	</section>
-</div>
+	</div>
+</FormBase>
 </template>
 
 <script lang="ts">
 import { defineComponent } from 'vue';
 import MkButton from '@client/components/ui/button.vue';
 import MkInput from '@client/components/ui/input.vue';
+import FormBase from '@client/components/form/base.vue';
+import FormButton from '@client/components/form/button.vue';
 import * as os from '@client/os';
 import * as symbols from '@client/symbols';
 
 export default defineComponent({
 	components: {
+		FormBase,
+		FormButton,
 		MkButton,
 		MkInput,
 	},
 
+	emits: ['info'],
+
 	data() {
 		return {
 			[symbols.PAGE_INFO]: {
 				title: this.$ts.relays,
-				icon: 'fas fa-project-diagram',
+				icon: 'fas fa-globe',
 			},
 			relays: [],
 			inbox: '',
@@ -49,8 +46,19 @@ export default defineComponent({
 		this.refresh();
 	},
 
+	mounted() {
+		this.$emit('info', this[symbols.PAGE_INFO]);
+	},
+
 	methods: {
-		add(inbox: string) {
+		async addRelay() {
+			const { canceled, result: inbox } = await os.dialog({
+				title: this.$ts.addRelay,
+				input: {
+					placeholder: this.$ts.inboxUrl
+				}
+			});
+			if (canceled) return;
 			os.api('admin/relays/add', {
 				inbox
 			}).then((relay: any) => {
@@ -86,9 +94,5 @@ export default defineComponent({
 </script>
 
 <style lang="scss" scoped>
-._content.relay {
-	div {
-		margin: 0.5em 0;
-	}
-}
+
 </style>
diff --git a/src/client/pages/instance/security.vue b/src/client/pages/instance/security.vue
new file mode 100644
index 0000000000..e3397a113b
--- /dev/null
+++ b/src/client/pages/instance/security.vue
@@ -0,0 +1,77 @@
+<template>
+<FormBase>
+	<FormSuspense :p="init">
+		<FormLink to="/instance/bot-protection">
+			<i class="fas fa-shield-alt"></i> {{ $ts.botProtection }}
+			<template #suffix v-if="enableHcaptcha">hCaptcha</template>
+			<template #suffix v-else-if="enableRecaptcha">reCAPTCHA</template>
+			<template #suffix v-else>{{ $ts.none }} ({{ $ts.notRecommended }})</template>
+		</FormLink>
+
+		<FormSwitch v-model:value="enableRegistration">{{ $ts.enableRegistration }}</FormSwitch>
+
+		<FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+	</FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import FormLink from '@client/components/form/link.vue';
+import FormSwitch from '@client/components/form/switch.vue';
+import FormButton from '@client/components/form/button.vue';
+import FormBase from '@client/components/form/base.vue';
+import FormGroup from '@client/components/form/group.vue';
+import FormInfo from '@client/components/form/info.vue';
+import FormSuspense from '@client/components/form/suspense.vue';
+import * as os from '@client/os';
+import * as symbols from '@client/symbols';
+import { fetchInstance } from '@client/instance';
+
+export default defineComponent({
+	components: {
+		FormLink,
+		FormSwitch,
+		FormBase,
+		FormGroup,
+		FormButton,
+		FormInfo,
+		FormSuspense,
+	},
+
+	emits: ['info'],
+
+	data() {
+		return {
+			[symbols.PAGE_INFO]: {
+				title: this.$ts.security,
+				icon: 'fas fa-lock'
+			},
+			enableHcaptcha: false,
+			enableRecaptcha: false,
+			enableRegistration: false,
+		}
+	},
+
+	async mounted() {
+		this.$emit('info', this[symbols.PAGE_INFO]);
+	},
+
+	methods: {
+		async init() {
+			const meta = await os.api('meta', { detail: true });
+			this.enableHcaptcha = meta.enableHcaptcha;
+			this.enableRecaptcha = meta.enableRecaptcha;
+			this.enableRegistration = !meta.disableRegistration;
+		},
+	
+		save() {
+			os.apiWithDialog('admin/update-meta', {
+				disableRegistration: !this.enableRegistration,
+			}).then(() => {
+				fetchInstance();
+			});
+		}
+	}
+});
+</script>
diff --git a/src/client/pages/instance/service-worker.vue b/src/client/pages/instance/service-worker.vue
new file mode 100644
index 0000000000..a52932bb75
--- /dev/null
+++ b/src/client/pages/instance/service-worker.vue
@@ -0,0 +1,84 @@
+<template>
+<FormBase>
+	<FormSuspense :p="init">
+		<FormSwitch v-model:value="enableServiceWorker">
+			{{ $ts.enableServiceworker }}
+			<template #desc>{{ $ts.serviceworkerInfo }}</template>
+		</FormSwitch>
+
+		<template v-if="enableServiceWorker">
+			<FormInput v-model:value="swPublicKey">
+				<template #prefix><i class="fas fa-key"></i></template>
+				Public key
+			</FormInput>
+
+			<FormInput v-model:value="swPrivateKey">
+				<template #prefix><i class="fas fa-key"></i></template>
+				Private key
+			</FormInput>
+		</template>
+
+		<FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+	</FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSwitch from '@client/components/form/switch.vue';
+import FormInput from '@client/components/form/input.vue';
+import FormButton from '@client/components/form/button.vue';
+import FormBase from '@client/components/form/base.vue';
+import FormGroup from '@client/components/form/group.vue';
+import FormSuspense from '@client/components/form/suspense.vue';
+import * as os from '@client/os';
+import * as symbols from '@client/symbols';
+import { fetchInstance } from '@client/instance';
+
+export default defineComponent({
+	components: {
+		FormSwitch,
+		FormInput,
+		FormBase,
+		FormGroup,
+		FormButton,
+		FormSuspense,
+	},
+
+	emits: ['info'],
+
+	data() {
+		return {
+			[symbols.PAGE_INFO]: {
+				title: 'ServiceWorker',
+				icon: 'fas fa-bolt'
+			},
+			enableServiceWorker: false,
+			swPublicKey: null,
+			swPrivateKey: null,
+		}
+	},
+
+	async mounted() {
+		this.$emit('info', this[symbols.PAGE_INFO]);
+	},
+
+	methods: {
+		async init() {
+			const meta = await os.api('meta', { detail: true });
+			this.enableServiceWorker = meta.enableServiceWorker;
+			this.swPublicKey = meta.swPublickey;
+			this.swPrivateKey = meta.swPrivateKey;
+		},
+		save() {
+			os.apiWithDialog('admin/update-meta', {
+				enableServiceWorker: this.enableServiceWorker,
+				swPublicKey: this.swPublicKey,
+				swPrivateKey: this.swPrivateKey,
+			}).then(() => {
+				fetchInstance();
+			});
+		}
+	}
+});
+</script>
diff --git a/src/client/pages/instance/settings.vue b/src/client/pages/instance/settings.vue
index b827a77649..66f01c42c7 100644
--- a/src/client/pages/instance/settings.vue
+++ b/src/client/pages/instance/settings.vue
@@ -1,581 +1,132 @@
 <template>
-<div v-if="meta" class="_section">
-	<section class="_card _gap">
-		<div class="_title"><i class="fas fa-info-circle"></i> {{ $ts.basicInfo }}</div>
-		<div class="_content">
-			<MkInput v-model:value="name">{{ $ts.instanceName }}</MkInput>
-			<MkTextarea v-model:value="description">{{ $ts.instanceDescription }}</MkTextarea>
-			<MkInput v-model:value="iconUrl"><template #icon><i class="fas fa-link"></i></template>{{ $ts.iconUrl }}</MkInput>
-			<MkInput v-model:value="bannerUrl"><template #icon><i class="fas fa-link"></i></template>{{ $ts.bannerUrl }}</MkInput>
-			<MkInput v-model:value="backgroundImageUrl"><template #icon><i class="fas fa-link"></i></template>{{ $ts.backgroundImageUrl }}</MkInput>
-			<MkInput v-model:value="logoImageUrl"><template #icon><i class="fas fa-link"></i></template>{{ $ts.logoImageUrl }}</MkInput>
-			<MkInput v-model:value="tosUrl"><template #icon><i class="fas fa-link"></i></template>{{ $ts.tosUrl }}</MkInput>
-			<MkInput v-model:value="maintainerName">{{ $ts.maintainerName }}</MkInput>
-			<MkInput v-model:value="maintainerEmail" type="email"><template #icon><i class="fas fa-envelope"></i></template>{{ $ts.maintainerEmail }}</MkInput>
-		</div>
-		<div class="_footer">
-			<MkButton primary @click="save(true)"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
-		</div>
-	</section>
-
-	<MkInput v-model:value="pinnedClipId">{{ $ts.pinnedClipId }}</MkInput>
-
-	<section class="_card _gap">
-		<div class="_content">
-			<MkInput v-model:value="maxNoteTextLength" type="number" :save="() => save()"><template #icon><i class="fas fa-pencil-alt"></i></template>{{ $ts.maxNoteTextLength }}</MkInput>
-		</div>
-		<div class="_content">
-			<MkSwitch v-model:value="enableLocalTimeline" @update:value="save()">{{ $ts.enableLocalTimeline }}</MkSwitch>
-			<MkSwitch v-model:value="enableGlobalTimeline" @update:value="save()">{{ $ts.enableGlobalTimeline }}</MkSwitch>
-			<MkInfo>{{ $ts.disablingTimelinesInfo }}</MkInfo>
-		</div>
-		<div class="_content">
-			<MkSwitch v-model:value="useStarForReactionFallback" @update:value="save()">{{ $ts.useStarForReactionFallback }}</MkSwitch>
-		</div>
-	</section>
-
-	<section class="_card _gap">
-		<div class="_title"><i class="fas fa-user"></i> {{ $ts.registration }}</div>
-		<div class="_content">
-			<MkSwitch v-model:value="enableRegistration" @update:value="save()">{{ $ts.enableRegistration }}</MkSwitch>
-			<MkButton v-if="!enableRegistration" @click="invite">{{ $ts.invite }}</MkButton>
-		</div>
-	</section>
-
-	<section class="_card _gap">
-		<div class="_title"><i class="fas fa-shield-alt"></i> {{ $ts.hcaptcha }}</div>
-		<div class="_content">
-			<MkSwitch v-model:value="enableHcaptcha">{{ $ts.enableHcaptcha }}</MkSwitch>
-			<template v-if="enableHcaptcha">
-				<MkInput v-model:value="hcaptchaSiteKey" :disabled="!enableHcaptcha"><template #icon><i class="fas fa-key"></i></template>{{ $ts.hcaptchaSiteKey }}</MkInput>
-				<MkInput v-model:value="hcaptchaSecretKey" :disabled="!enableHcaptcha"><template #icon><i class="fas fa-key"></i></template>{{ $ts.hcaptchaSecretKey }}</MkInput>
-			</template>
-		</div>
-		<div class="_content" v-if="enableHcaptcha">
-			<header>{{ $ts.preview }}</header>
-			<captcha v-if="enableHcaptcha" provider="hcaptcha" :sitekey="hcaptchaSiteKey || '10000000-ffff-ffff-ffff-000000000001'"/>
-		</div>
-		<div class="_footer">
-			<MkButton primary @click="save(true)"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
-		</div>
-	</section>
-
-	<section class="_card _gap">
-		<div class="_title"><i class="fas fa-shield-alt"></i> {{ $ts.recaptcha }}</div>
-		<div class="_content">
-			<MkSwitch v-model:value="enableRecaptcha" ref="enableRecaptcha">{{ $ts.enableRecaptcha }}</MkSwitch>
-			<template v-if="enableRecaptcha">
-				<MkInput v-model:value="recaptchaSiteKey" :disabled="!enableRecaptcha"><template #icon><i class="fas fa-key"></i></template>{{ $ts.recaptchaSiteKey }}</MkInput>
-				<MkInput v-model:value="recaptchaSecretKey" :disabled="!enableRecaptcha"><template #icon><i class="fas fa-key"></i></template>{{ $ts.recaptchaSecretKey }}</MkInput>
-			</template>
-		</div>
-		<div class="_content" v-if="enableRecaptcha && recaptchaSiteKey">
-			<header>{{ $ts.preview }}</header>
-			<captcha v-if="enableRecaptcha" provider="grecaptcha" :sitekey="recaptchaSiteKey"/>
-		</div>
-		<div class="_footer">
-			<MkButton primary @click="save(true)"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
-		</div>
-	</section>
-
-	<section class="_card _gap">
-		<div class="_title"><i class="fas fa-envelope"></i> {{ $ts.emailConfig }}</div>
-		<div class="_content">
-			<MkSwitch v-model:value="enableEmail" @update:value="save()">{{ $ts.enableEmail }}<template #desc>{{ $ts.emailConfigInfo }}</template></MkSwitch>
-			<MkInput v-model:value="email" type="email" :disabled="!enableEmail">{{ $ts.email }}</MkInput>
-			<div><b>{{ $ts.smtpConfig }}</b></div>
-			<div class="_inputs">
-				<MkInput v-model:value="smtpHost" :disabled="!enableEmail">{{ $ts.smtpHost }}</MkInput>
-				<MkInput v-model:value="smtpPort" type="number" :disabled="!enableEmail">{{ $ts.smtpPort }}</MkInput>
-			</div>
-			<div class="_inputs">
-				<MkInput v-model:value="smtpUser" :disabled="!enableEmail">{{ $ts.smtpUser }}</MkInput>
-				<MkInput v-model:value="smtpPass" type="password" :disabled="!enableEmail">{{ $ts.smtpPass }}</MkInput>
-			</div>
-			<MkInfo>{{ $ts.emptyToDisableSmtpAuth }}</MkInfo>
-			<MkSwitch v-model:value="smtpSecure" :disabled="!enableEmail">{{ $ts.smtpSecure }}<template #desc>{{ $ts.smtpSecureInfo }}</template></MkSwitch>
-			<div>
-				<MkButton :disabled="!enableEmail" primary inline @click="save(true)"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
-				<MkButton :disabled="!enableEmail" inline @click="testEmail()">{{ $ts.testEmail }}</MkButton>
-			</div>
-		</div>
-	</section>
-
-	<section class="_card _gap">
-		<div class="_title"><i class="fas fa-bolt"></i> {{ $ts.serviceworker }}</div>
-		<div class="_content">
-			<MkSwitch v-model:value="enableServiceWorker">{{ $ts.enableServiceworker }}<template #desc>{{ $ts.serviceworkerInfo }}</template></MkSwitch>
-			<template v-if="enableServiceWorker">
-				<div class="_inputs">
-					<MkInput v-model:value="swPublicKey" :disabled="!enableServiceWorker"><template #icon><i class="fas fa-key"></i></template>Public key</MkInput>
-					<MkInput v-model:value="swPrivateKey" :disabled="!enableServiceWorker"><template #icon><i class="fas fa-key"></i></template>Private key</MkInput>
-				</div>
-			</template>
-		</div>
-		<div class="_footer">
-			<MkButton primary @click="save(true)"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
-		</div>
-	</section>
-
-	<section class="_card _gap">
-		<div class="_title"><i class="fas fa-thumbtack"></i> {{ $ts.pinnedUsers }}</div>
-		<div class="_content">
-			<MkTextarea v-model:value="pinnedUsers">
-				<template #desc>{{ $ts.pinnedUsersDescription }} <button class="_textButton" @click="addPinUser">{{ $ts.addUser }}</button></template>
-			</MkTextarea>
-		</div>
-		<div class="_footer">
-			<MkButton primary @click="save(true)"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
-		</div>
-	</section>
-
-	<section class="_card _gap">
-		<div class="_title"><i class="fas fa-thumbtack"></i> {{ $ts.pinnedPages }}</div>
-		<div class="_content">
-			<MkTextarea v-model:value="pinnedPages">
-				<template #desc>{{ $ts.pinnedPagesDescription }}</template>
-			</MkTextarea>
-		</div>
-		<div class="_footer">
-			<MkButton primary @click="save(true)"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
-		</div>
-	</section>
-
-	<section class="_card _gap">
-		<div class="_title"><i class="fas fa-cloud"></i> {{ $ts.files }}</div>
-		<div class="_content">
-			<MkSwitch v-model:value="cacheRemoteFiles">{{ $ts.cacheRemoteFiles }}<template #desc>{{ $ts.cacheRemoteFilesDescription }}</template></MkSwitch>
-			<MkSwitch v-model:value="proxyRemoteFiles">{{ $ts.proxyRemoteFiles }}<template #desc>{{ $ts.proxyRemoteFilesDescription }}</template></MkSwitch>
-			<MkInput v-model:value="localDriveCapacityMb" type="number">{{ $ts.driveCapacityPerLocalAccount }}<template #suffix>MB</template><template #desc>{{ $ts.inMb }}</template></MkInput>
-			<MkInput v-model:value="remoteDriveCapacityMb" type="number" :disabled="!cacheRemoteFiles">{{ $ts.driveCapacityPerRemoteAccount }}<template #suffix>MB</template><template #desc>{{ $ts.inMb }}</template></MkInput>
-		</div>
-		<div class="_footer">
-			<MkButton primary @click="save(true)"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
-		</div>
-	</section>
-
-	<section class="_card _gap">
-		<div class="_title"><i class="fas fa-cloud"></i> {{ $ts.objectStorage }}</div>
-		<div class="_content">
-			<MkSwitch v-model:value="useObjectStorage">{{ $ts.useObjectStorage }}</MkSwitch>
-			<template v-if="useObjectStorage">
-				<MkInput v-model:value="objectStorageBaseUrl" :disabled="!useObjectStorage">{{ $ts.objectStorageBaseUrl }}<template #desc>{{ $ts.objectStorageBaseUrlDesc }}</template></MkInput>
-				<div class="_inputs">
-					<MkInput v-model:value="objectStorageBucket" :disabled="!useObjectStorage">{{ $ts.objectStorageBucket }}<template #desc>{{ $ts.objectStorageBucketDesc }}</template></MkInput>
-					<MkInput v-model:value="objectStoragePrefix" :disabled="!useObjectStorage">{{ $ts.objectStoragePrefix }}<template #desc>{{ $ts.objectStoragePrefixDesc }}</template></MkInput>
-				</div>
-				<MkInput v-model:value="objectStorageEndpoint" :disabled="!useObjectStorage">{{ $ts.objectStorageEndpoint }}<template #desc>{{ $ts.objectStorageEndpointDesc }}</template></MkInput>
-				<div class="_inputs">
-					<MkInput v-model:value="objectStorageRegion" :disabled="!useObjectStorage">{{ $ts.objectStorageRegion }}<template #desc>{{ $ts.objectStorageRegionDesc }}</template></MkInput>
-				</div>
-				<div class="_inputs">
-					<MkInput v-model:value="objectStorageAccessKey" :disabled="!useObjectStorage"><template #icon><i class="fas fa-key"></i></template>Access key</MkInput>
-					<MkInput v-model:value="objectStorageSecretKey" :disabled="!useObjectStorage"><template #icon><i class="fas fa-key"></i></template>Secret key</MkInput>
-				</div>
-				<MkSwitch v-model:value="objectStorageUseSSL" :disabled="!useObjectStorage">{{ $ts.objectStorageUseSSL }}<template #desc>{{ $ts.objectStorageUseSSLDesc }}</template></MkSwitch>
-				<MkSwitch v-model:value="objectStorageUseProxy" :disabled="!useObjectStorage">{{ $ts.objectStorageUseProxy }}<template #desc>{{ $ts.objectStorageUseProxyDesc }}</template></MkSwitch>
-				<MkSwitch v-model:value="objectStorageSetPublicRead" :disabled="!useObjectStorage">{{ $ts.objectStorageSetPublicRead }}</MkSwitch>
-				<MkSwitch v-model:value="objectStorageS3ForcePathStyle" :disabled="!useObjectStorage">s3ForcePathStyle</MkSwitch>
-			</template>
-		</div>
-		<div class="_footer">
-			<MkButton primary @click="save(true)"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
-		</div>
-	</section>
-
-	<section class="_card _gap">
-		<div class="_title"><i class="fas fa-ghost"></i> {{ $ts.proxyAccount }}</div>
-		<div class="_content">
-			<MkInput :value="proxyAccount ? proxyAccount.username : null" disabled><template #prefix>@</template>{{ $ts.proxyAccount }}<template #desc>{{ $ts.proxyAccountDescription }}</template></MkInput>
-			<MkButton primary @click="chooseProxyAccount">{{ $ts.chooseProxyAccount }}</MkButton>
-		</div>
-	</section>
-
-	<section class="_card _gap">
-		<div class="_title"><i class="fas fa-ban"></i> {{ $ts.blockedInstances }}</div>
-		<div class="_content">
-			<MkTextarea v-model:value="blockedHosts">
-				<template #desc>{{ $ts.blockedInstancesDescription }}</template>
-			</MkTextarea>
-		</div>
-		<div class="_footer">
-			<MkButton primary @click="save(true)"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
-		</div>
-	</section>
-
-	<section class="_card _gap">
-		<div class="_title"><i class="fas fa-share-alt"></i> {{ $ts.integration }}</div>
-		<div class="_content">
-			<header><i class="fab fa-twitter"></i> Twitter</header>
-			<MkSwitch v-model:value="enableTwitterIntegration">{{ $ts.enable }}</MkSwitch>
-			<template v-if="enableTwitterIntegration">
-				<MkInfo>Callback URL: {{ `${url}/api/tw/cb` }}</MkInfo>
-				<MkInput v-model:value="twitterConsumerKey" :disabled="!enableTwitterIntegration"><template #icon><i class="fas fa-key"></i></template>Consumer Key</MkInput>
-				<MkInput v-model:value="twitterConsumerSecret" :disabled="!enableTwitterIntegration"><template #icon><i class="fas fa-key"></i></template>Consumer Secret</MkInput>
-			</template>
-		</div>
-		<div class="_content">
-			<header><i class="fas fa-github"></i> GitHub</header>
-			<MkSwitch v-model:value="enableGithubIntegration">{{ $ts.enable }}</MkSwitch>
-			<template v-if="enableGithubIntegration">
-				<MkInfo>Callback URL: {{ `${url}/api/gh/cb` }}</MkInfo>
-				<MkInput v-model:value="githubClientId" :disabled="!enableGithubIntegration"><template #icon><i class="fas fa-key"></i></template>Client ID</MkInput>
-				<MkInput v-model:value="githubClientSecret" :disabled="!enableGithubIntegration"><template #icon><i class="fas fa-key"></i></template>Client Secret</MkInput>
-			</template>
-		</div>
-		<div class="_content">
-			<header><i class="fas fa-discord"></i> Discord</header>
-			<MkSwitch v-model:value="enableDiscordIntegration">{{ $ts.enable }}</MkSwitch>
-			<template v-if="enableDiscordIntegration">
-				<MkInfo>Callback URL: {{ `${url}/api/dc/cb` }}</MkInfo>
-				<MkInput v-model:value="discordClientId" :disabled="!enableDiscordIntegration"><template #icon><i class="fas fa-key"></i></template>Client ID</MkInput>
-				<MkInput v-model:value="discordClientSecret" :disabled="!enableDiscordIntegration"><template #icon><i class="fas fa-key"></i></template>Client Secret</MkInput>
-			</template>
-		</div>
-		<div class="_footer">
-			<MkButton primary @click="save(true)"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
-		</div>
-	</section>
-
-	<section class="_card _gap">
-		<div class="_title"><i class="fas fa-archway"></i> Summaly Proxy</div>
-		<div class="_content">
-			<MkInput v-model:value="summalyProxy">URL</MkInput>
-			<MkButton primary @click="save(true)"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
-		</div>
-	</section>
-</div>
+<FormBase>
+	<FormSuspense :p="init">
+		<FormInput v-model:value="name">
+			<span>{{ $ts.instanceName }}</span>
+		</FormInput>
+
+		<FormTextarea v-model:value="description">
+			<span>{{ $ts.instanceDescription }}</span>
+		</FormTextarea>
+
+		<FormInput v-model:value="iconUrl">
+			<template #prefix><i class="fas fa-link"></i></template>
+			<span>{{ $ts.iconUrl }}</span>
+		</FormInput>
+
+		<FormInput v-model:value="bannerUrl">
+			<template #prefix><i class="fas fa-link"></i></template>
+			<span>{{ $ts.bannerUrl }}</span>
+		</FormInput>
+
+		<FormInput v-model:value="tosUrl">
+			<template #prefix><i class="fas fa-link"></i></template>
+			<span>{{ $ts.tosUrl }}</span>
+		</FormInput>
+
+		<FormInput v-model:value="maintainerName">
+			<span>{{ $ts.maintainerName }}</span>
+		</FormInput>
+
+		<FormInput v-model:value="maintainerEmail" type="email">
+			<template #prefix><i class="fas fa-envelope"></i></template>
+			<span>{{ $ts.maintainerEmail }}</span>
+		</FormInput>
+
+		<FormInput v-model:value="maxNoteTextLength" type="number">
+			<template #prefix><i class="fas fa-pencil-alt"></i></template>
+			<span>{{ $ts.maxNoteTextLength }}</span>
+		</FormInput>
+
+		<FormSwitch v-model:value="enableLocalTimeline">{{ $ts.enableLocalTimeline }}</FormSwitch>
+		<FormSwitch v-model:value="enableGlobalTimeline">{{ $ts.enableGlobalTimeline }}</FormSwitch>
+		<FormInfo>{{ $ts.disablingTimelinesInfo }}</FormInfo>
+
+		<FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+	</FormSuspense>
+</FormBase>
 </template>
 
 <script lang="ts">
-import { defineComponent, defineAsyncComponent } from 'vue';
-import MkButton from '@client/components/ui/button.vue';
-import MkInput from '@client/components/ui/input.vue';
-import MkTextarea from '@client/components/ui/textarea.vue';
-import MkSwitch from '@client/components/ui/switch.vue';
-import MkInfo from '@client/components/ui/info.vue';
-import { url } from '@client/config';
-import getAcct from '@/misc/acct/render';
+import { defineComponent } from 'vue';
+import FormSwitch from '@client/components/form/switch.vue';
+import FormInput from '@client/components/form/input.vue';
+import FormButton from '@client/components/form/button.vue';
+import FormBase from '@client/components/form/base.vue';
+import FormGroup from '@client/components/form/group.vue';
+import FormTextarea from '@client/components/form/textarea.vue';
+import FormInfo from '@client/components/form/info.vue';
+import FormSuspense from '@client/components/form/suspense.vue';
 import * as os from '@client/os';
-import { fetchInstance } from '@client/instance';
 import * as symbols from '@client/symbols';
+import { fetchInstance } from '@client/instance';
 
 export default defineComponent({
 	components: {
-		MkButton,
-		MkInput,
-		MkTextarea,
-		MkSwitch,
-		MkInfo,
-		Captcha: defineAsyncComponent(() => import('@client/components/captcha.vue')),
+		FormSwitch,
+		FormInput,
+		FormBase,
+		FormGroup,
+		FormButton,
+		FormTextarea,
+		FormInfo,
+		FormSuspense,
 	},
 
+	emits: ['info'],
+
 	data() {
 		return {
 			[symbols.PAGE_INFO]: {
-				title: this.$ts.instance,
-				icon: 'fas fa-cog',
+				title: this.$ts.general,
+				icon: 'fas fa-cog'
 			},
-			meta: null,
-			url,
-			proxyAccount: null,
-			proxyAccountId: null,
-			cacheRemoteFiles: false,
-			proxyRemoteFiles: false,
-			localDriveCapacityMb: 0,
-			remoteDriveCapacityMb: 0,
-			blockedHosts: '',
-			pinnedUsers: '',
-			pinnedPages: '',
-			pinnedClipId: null,
-			maintainerName: null,
-			maintainerEmail: null,
 			name: null,
 			description: null,
 			tosUrl: null as string | null,
-			enableEmail: false,
-			email: null,
-			bannerUrl: null,
+			maintainerName: null,
+			maintainerEmail: null,
 			iconUrl: null,
-			logoImageUrl: null,
-			backgroundImageUrl: null,
+			bannerUrl: null,
 			maxNoteTextLength: 0,
-			enableRegistration: false,
 			enableLocalTimeline: false,
 			enableGlobalTimeline: false,
-			enableHcaptcha: false,
-			hcaptchaSiteKey: null,
-			hcaptchaSecretKey: null,
-			enableRecaptcha: false,
-			recaptchaSiteKey: null,
-			recaptchaSecretKey: null,
-			enableServiceWorker: false,
-			swPublicKey: null,
-			swPrivateKey: null,
-			useObjectStorage: false,
-			objectStorageBaseUrl: null,
-			objectStorageBucket: null,
-			objectStoragePrefix: null,
-			objectStorageEndpoint: null,
-			objectStorageRegion: null,
-			objectStoragePort: null,
-			objectStorageAccessKey: null,
-			objectStorageSecretKey: null,
-			objectStorageUseSSL: false,
-			objectStorageUseProxy: false,
-			objectStorageSetPublicRead: false,
-			objectStorageS3ForcePathStyle: true,
-			enableTwitterIntegration: false,
-			twitterConsumerKey: null,
-			twitterConsumerSecret: null,
-			enableGithubIntegration: false,
-			githubClientId: null,
-			githubClientSecret: null,
-			enableDiscordIntegration: false,
-			discordClientId: null,
-			discordClientSecret: null,
-			useStarForReactionFallback: false,
-			smtpSecure: false,
-			smtpHost: '',
-			smtpPort: 0,
-			smtpUser: '',
-			smtpPass: '',
-			summalyProxy: '',
 		}
 	},
 
-	async created() {
-		this.meta = await os.api('meta', { detail: true });
-
-		this.name = this.meta.name;
-		this.description = this.meta.description;
-		this.tosUrl = this.meta.tosUrl;
-		this.bannerUrl = this.meta.bannerUrl;
-		this.iconUrl = this.meta.iconUrl;
-		this.logoImageUrl = this.meta.logoImageUrl;
-		this.backgroundImageUrl = this.meta.backgroundImageUrl;
-		this.enableEmail = this.meta.enableEmail;
-		this.email = this.meta.email;
-		this.maintainerName = this.meta.maintainerName;
-		this.maintainerEmail = this.meta.maintainerEmail;
-		this.maxNoteTextLength = this.meta.maxNoteTextLength;
-		this.enableRegistration = !this.meta.disableRegistration;
-		this.enableLocalTimeline = !this.meta.disableLocalTimeline;
-		this.enableGlobalTimeline = !this.meta.disableGlobalTimeline;
-		this.enableHcaptcha = this.meta.enableHcaptcha;
-		this.hcaptchaSiteKey = this.meta.hcaptchaSiteKey;
-		this.hcaptchaSecretKey = this.meta.hcaptchaSecretKey;
-		this.enableRecaptcha = this.meta.enableRecaptcha;
-		this.recaptchaSiteKey = this.meta.recaptchaSiteKey;
-		this.recaptchaSecretKey = this.meta.recaptchaSecretKey;
-		this.proxyAccountId = this.meta.proxyAccountId;
-		this.cacheRemoteFiles = this.meta.cacheRemoteFiles;
-		this.proxyRemoteFiles = this.meta.proxyRemoteFiles;
-		this.localDriveCapacityMb = this.meta.driveCapacityPerLocalUserMb;
-		this.remoteDriveCapacityMb = this.meta.driveCapacityPerRemoteUserMb;
-		this.blockedHosts = this.meta.blockedHosts.join('\n');
-		this.pinnedUsers = this.meta.pinnedUsers.join('\n');
-		this.pinnedPages = this.meta.pinnedPages.join('\n');
-		this.pinnedClipId = this.meta.pinnedClipId;
-		this.enableServiceWorker = this.meta.enableServiceWorker;
-		this.swPublicKey = this.meta.swPublickey;
-		this.swPrivateKey = this.meta.swPrivateKey;
-		this.useObjectStorage = this.meta.useObjectStorage;
-		this.objectStorageBaseUrl = this.meta.objectStorageBaseUrl;
-		this.objectStorageBucket = this.meta.objectStorageBucket;
-		this.objectStoragePrefix = this.meta.objectStoragePrefix;
-		this.objectStorageEndpoint = this.meta.objectStorageEndpoint;
-		this.objectStorageRegion = this.meta.objectStorageRegion;
-		this.objectStoragePort = this.meta.objectStoragePort;
-		this.objectStorageAccessKey = this.meta.objectStorageAccessKey;
-		this.objectStorageSecretKey = this.meta.objectStorageSecretKey;
-		this.objectStorageUseSSL = this.meta.objectStorageUseSSL;
-		this.objectStorageUseProxy = this.meta.objectStorageUseProxy;
-		this.objectStorageSetPublicRead = this.meta.objectStorageSetPublicRead;
-		this.objectStorageS3ForcePathStyle = this.meta.objectStorageS3ForcePathStyle;
-		this.enableTwitterIntegration = this.meta.enableTwitterIntegration;
-		this.twitterConsumerKey = this.meta.twitterConsumerKey;
-		this.twitterConsumerSecret = this.meta.twitterConsumerSecret;
-		this.enableGithubIntegration = this.meta.enableGithubIntegration;
-		this.githubClientId = this.meta.githubClientId;
-		this.githubClientSecret = this.meta.githubClientSecret;
-		this.enableDiscordIntegration = this.meta.enableDiscordIntegration;
-		this.discordClientId = this.meta.discordClientId;
-		this.discordClientSecret = this.meta.discordClientSecret;
-		this.useStarForReactionFallback = this.meta.useStarForReactionFallback;
-		this.smtpSecure = this.meta.smtpSecure;
-		this.smtpHost = this.meta.smtpHost;
-		this.smtpPort = this.meta.smtpPort;
-		this.smtpUser = this.meta.smtpUser;
-		this.smtpPass = this.meta.smtpPass;
-		this.summalyProxy = this.meta.summalyProxy;
-
-		if (this.proxyAccountId) {
-			os.api('users/show', { userId: this.proxyAccountId }).then(proxyAccount => {
-				this.proxyAccount = proxyAccount;
-			});
-		}
-	},
-
-	mounted() {
-		this.$watch('enableHcaptcha', () => {
-			if (this.enableHcaptcha && this.enableRecaptcha) {
-				os.dialog({
-					type: 'question', // warning だと間違って cancel するかもしれない
-					showCancelButton: true,
-					title: this.$ts.settingGuide,
-					text: this.$ts.avoidMultiCaptchaConfirm,
-				}).then(({ canceled }) => {
-					if (canceled) {
-						return;
-					}
-
-					this.enableRecaptcha = false;
-				});
-			}
-		});
-
-		this.$watch('enableRecaptcha', () => {
-			if (this.enableRecaptcha && this.enableHcaptcha) {
-				os.dialog({
-					type: 'question', // warning だと間違って cancel するかもしれない
-					showCancelButton: true,
-					title: this.$ts.settingGuide,
-					text: this.$ts.avoidMultiCaptchaConfirm,
-				}).then(({ canceled }) => {
-					if (canceled) {
-						return;
-					}
-
-					this.enableHcaptcha = false;
-				});
-			}
-		});
+	async mounted() {
+		this.$emit('info', this[symbols.PAGE_INFO]);
 	},
 
 	methods: {
-		invite() {
-			os.api('admin/invite').then(x => {
-				os.dialog({
-					type: 'info',
-					text: x.code
-				});
-			}).catch(e => {
-				os.dialog({
-					type: 'error',
-					text: e
-				});
-			});
+		async init() {
+			const meta = await os.api('meta', { detail: true });
+			this.name = meta.name;
+			this.description = meta.description;
+			this.tosUrl = meta.tosUrl;
+			this.iconUrl = meta.iconUrl;
+			this.bannerUrl = meta.bannerUrl;
+			this.maintainerName = meta.maintainerName;
+			this.maintainerEmail = meta.maintainerEmail;
+			this.maxNoteTextLength = meta.maxNoteTextLength;
+			this.enableLocalTimeline = !meta.disableLocalTimeline;
+			this.enableGlobalTimeline = !meta.disableGlobalTimeline;
 		},
 
-		addPinUser() {
-			os.selectUser().then(user => {
-				this.pinnedUsers = this.pinnedUsers.trim();
-				this.pinnedUsers += '\n@' + getAcct(user);
-				this.pinnedUsers = this.pinnedUsers.trim();
-			});
-		},
-
-		chooseProxyAccount() {
-			os.selectUser().then(user => {
-				this.proxyAccount = user;
-				this.proxyAccountId = user.id;
-				this.save(true);
-			});
-		},
-
-		async testEmail() {
-			os.api('admin/send-email', {
-				to: this.maintainerEmail,
-				subject: 'Test email',
-				text: 'Yo'
-			}).then(x => {
-				os.dialog({
-					type: 'success',
-					splash: true
-				});
-			}).catch(e => {
-				os.dialog({
-					type: 'error',
-					text: e
-				});
-			});
-		},
-
-		save(withDialog = false) {
-			os.api('admin/update-meta', {
+		save() {
+			os.apiWithDialog('admin/update-meta', {
 				name: this.name,
 				description: this.description,
 				tosUrl: this.tosUrl,
-				bannerUrl: this.bannerUrl,
 				iconUrl: this.iconUrl,
-				logoImageUrl: this.logoImageUrl,
-				backgroundImageUrl: this.backgroundImageUrl,
+				bannerUrl: this.bannerUrl,
 				maintainerName: this.maintainerName,
 				maintainerEmail: this.maintainerEmail,
 				maxNoteTextLength: this.maxNoteTextLength,
-				disableRegistration: !this.enableRegistration,
 				disableLocalTimeline: !this.enableLocalTimeline,
 				disableGlobalTimeline: !this.enableGlobalTimeline,
-				enableHcaptcha: this.enableHcaptcha,
-				hcaptchaSiteKey: this.hcaptchaSiteKey,
-				hcaptchaSecretKey: this.hcaptchaSecretKey,
-				enableRecaptcha: this.enableRecaptcha,
-				recaptchaSiteKey: this.recaptchaSiteKey,
-				recaptchaSecretKey: this.recaptchaSecretKey,
-				proxyAccountId: this.proxyAccountId,
-				cacheRemoteFiles: this.cacheRemoteFiles,
-				proxyRemoteFiles: this.proxyRemoteFiles,
-				localDriveCapacityMb: parseInt(this.localDriveCapacityMb, 10),
-				remoteDriveCapacityMb: parseInt(this.remoteDriveCapacityMb, 10),
-				blockedHosts: this.blockedHosts.split('\n') || [],
-				pinnedUsers: this.pinnedUsers ? this.pinnedUsers.split('\n') : [],
-				pinnedPages: this.pinnedPages ? this.pinnedPages.split('\n') : [],
-				pinnedClipId: (this.pinnedClipId && this.pinnedClipId) != '' ? this.pinnedClipId : null,
-				enableServiceWorker: this.enableServiceWorker,
-				swPublicKey: this.swPublicKey,
-				swPrivateKey: this.swPrivateKey,
-				useObjectStorage: this.useObjectStorage,
-				objectStorageBaseUrl: this.objectStorageBaseUrl ? this.objectStorageBaseUrl : null,
-				objectStorageBucket: this.objectStorageBucket ? this.objectStorageBucket : null,
-				objectStoragePrefix: this.objectStoragePrefix ? this.objectStoragePrefix : null,
-				objectStorageEndpoint: this.objectStorageEndpoint ? this.objectStorageEndpoint : null,
-				objectStorageRegion: this.objectStorageRegion ? this.objectStorageRegion : null,
-				objectStoragePort: this.objectStoragePort ? this.objectStoragePort : null,
-				objectStorageAccessKey: this.objectStorageAccessKey ? this.objectStorageAccessKey : null,
-				objectStorageSecretKey: this.objectStorageSecretKey ? this.objectStorageSecretKey : null,
-				objectStorageUseSSL: this.objectStorageUseSSL,
-				objectStorageUseProxy: this.objectStorageUseProxy,
-				objectStorageSetPublicRead: this.objectStorageSetPublicRead,
-				objectStorageS3ForcePathStyle: this.objectStorageS3ForcePathStyle,
-				enableTwitterIntegration: this.enableTwitterIntegration,
-				twitterConsumerKey: this.twitterConsumerKey,
-				twitterConsumerSecret: this.twitterConsumerSecret,
-				enableGithubIntegration: this.enableGithubIntegration,
-				githubClientId: this.githubClientId,
-				githubClientSecret: this.githubClientSecret,
-				enableDiscordIntegration: this.enableDiscordIntegration,
-				discordClientId: this.discordClientId,
-				discordClientSecret: this.discordClientSecret,
-				enableEmail: this.enableEmail,
-				email: this.email,
-				smtpSecure: this.smtpSecure,
-				smtpHost: this.smtpHost,
-				smtpPort: this.smtpPort,
-				smtpUser: this.smtpUser,
-				smtpPass: this.smtpPass,
-				summalyProxy: this.summalyProxy,
-				useStarForReactionFallback: this.useStarForReactionFallback,
 			}).then(() => {
 				fetchInstance();
-				if (withDialog) {
-					os.success();
-				}
-			}).catch(e => {
-				os.dialog({
-					type: 'error',
-					text: e
-				});
 			});
 		}
 	}
diff --git a/src/client/pages/instance/user-dialog.vue b/src/client/pages/instance/user-dialog.vue
deleted file mode 100644
index d7d627191e..0000000000
--- a/src/client/pages/instance/user-dialog.vue
+++ /dev/null
@@ -1,230 +0,0 @@
-<template>
-<XModalWindow ref="dialog"
-	:width="370"
-	@close="$refs.dialog.close()"
-	@closed="$emit('closed')"
->
-	<template #header v-if="user"><MkUserName class="name" :user="user"/></template>
-	<div class="vrcsvlkm" v-if="user && info">
-		<div class="_section">
-			<div class="banner" :style="bannerStyle">
-				<MkAvatar class="avatar" :user="user" :show-indicator="true"/>
-			</div>
-		</div>
-		<div class="_section">
-			<div class="title">
-				<span class="acct">@{{ acct(user) }}</span>
-			</div>
-			<div class="status">
-				<span class="staff" v-if="user.isAdmin"><i class="fas fa-bookmark"></i></span>
-				<span class="staff" v-if="user.isModerator"><i class="far fa-bookmark"></i></span>
-				<span class="punished" v-if="user.isSilenced"><i class="fas fa-microphone-slash"></i></span>
-				<span class="punished" v-if="user.isSuspended"><i class="fas fa-snowflake"></i></span>
-			</div>
-		</div>
-		<div class="_section">
-			<div class="_content">
-				<MkSwitch v-if="user.host == null && $i.isAdmin && (moderator || !user.isAdmin)" @update:value="toggleModerator" v-model:value="moderator">{{ $ts.moderator }}</MkSwitch>
-				<MkSwitch @update:value="toggleSilence" v-model:value="silenced">{{ $ts.silence }}</MkSwitch>
-				<MkSwitch @update:value="toggleSuspend" v-model:value="suspended">{{ $ts.suspend }}</MkSwitch>
-			</div>
-		</div>
-		<div class="_section">
-			<div class="_content">
-				<MkButton full @click="openProfile"><i class="fas fa-external-link-square-alt"></i> {{ $ts.profile }}</MkButton>
-				<MkButton full v-if="user.host != null" @click="updateRemoteUser"><i class="fas fa-sync"></i> {{ $ts.updateRemoteUser }}</MkButton>
-				<MkButton full @click="resetPassword"><i class="fas fa-key"></i> {{ $ts.resetPassword }}</MkButton>
-				<MkButton full @click="deleteAllFiles" danger><i class="fas fa-trash-alt"></i> {{ $ts.deleteAllFiles }}</MkButton>
-			</div>
-		</div>
-		<div class="_section">
-			<details class="_content rawdata">
-				<pre><code>{{ JSON.stringify(info, null, 2) }}</code></pre>
-			</details>
-		</div>
-	</div>
-</XModalWindow>
-</template>
-
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
-import MkButton from '@client/components/ui/button.vue';
-import MkSwitch from '@client/components/ui/switch.vue';
-import XModalWindow from '@client/components/ui/modal-window.vue';
-import Progress from '@client/scripts/loading';
-import { acct, userPage } from '../../filters/user';
-import * as os from '@client/os';
-
-export default defineComponent({
-	components: {
-		MkButton,
-		MkSwitch,
-		XModalWindow,
-	},
-
-	props: {
-		userId: {
-			required: true,
-		}
-	},
-
-	emits: ['closed'],
-
-	data() {
-		return {
-			user: null,
-			info: null,
-			moderator: false,
-			silenced: false,
-			suspended: false,
-		};
-	},
-
-	computed: {
-		bannerStyle(): any {
-			if (this.user.bannerUrl == null) return {};
-			return {
-				backgroundImage: `url(${ this.user.bannerUrl })`
-			};
-		},
-	},
-
-	created() {
-		this.fetch();
-	},
-
-	methods: {
-		async fetch() {
-			Progress.start();
-			this.user = await os.api('users/show', { userId: this.userId });
-			this.info = await os.api('admin/show-user', { userId: this.userId });
-			this.moderator = this.info.isModerator;
-			this.silenced = this.info.isSilenced;
-			this.suspended = this.info.isSuspended;
-			Progress.done();
-		},
-
-		/** 処理対象ユーザーの情報を更新する */
-		async refreshUser() {
-			this.user = await os.api('users/show', { userId: this.user.id });
-			this.info = await os.api('admin/show-user', { userId: this.user.id });
-		},
-
-		openProfile() {
-			window.open(userPage(this.user, null, true), '_blank');
-		},
-
-		async updateRemoteUser() {
-			await os.api('admin/update-remote-user', { userId: this.user.id }).then(res => {
-				os.success();
-			});
-			await this.refreshUser();
-		},
-
-		async resetPassword() {
-			os.apiWithDialog('admin/reset-password', {
-				userId: this.user.id,
-			}, undefined, ({ password }) => {
-				os.dialog({
-					type: 'success',
-					text: this.$t('newPasswordIs', { password })
-				});
-			});
-		},
-
-		async toggleSilence(v) {
-			const confirm = await os.dialog({
-				type: 'warning',
-				showCancelButton: true,
-				text: v ? this.$ts.silenceConfirm : this.$ts.unsilenceConfirm,
-			});
-			if (confirm.canceled) {
-				this.silenced = !v;
-			} else {
-				await os.api(v ? 'admin/silence-user' : 'admin/unsilence-user', { userId: this.user.id });
-				await this.refreshUser();
-			}
-		},
-
-		async toggleSuspend(v) {
-			const confirm = await os.dialog({
-				type: 'warning',
-				showCancelButton: true,
-				text: v ? this.$ts.suspendConfirm : this.$ts.unsuspendConfirm,
-			});
-			if (confirm.canceled) {
-				this.suspended = !v;
-			} else {
-				await os.api(v ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: this.user.id });
-				await this.refreshUser();
-			}
-		},
-
-		async toggleModerator(v) {
-			await os.api(v ? 'admin/moderators/add' : 'admin/moderators/remove', { userId: this.user.id });
-			await this.refreshUser();
-		},
-
-		async deleteAllFiles() {
-			const confirm = await os.dialog({
-				type: 'warning',
-				showCancelButton: true,
-				text: this.$ts.deleteAllFilesConfirm,
-			});
-			if (confirm.canceled) return;
-			const process = async () => {
-				await os.api('admin/delete-all-files-of-a-user', { userId: this.user.id });
-				os.success();
-			};
-			await process().catch(e => {
-				os.dialog({
-					type: 'error',
-					text: e.toString()
-				});
-			});
-			await this.refreshUser();
-		},
-
-		acct
-	}
-});
-</script>
-
-<style lang="scss" scoped>
-.vrcsvlkm {
-	> ._section {
-		> .banner {
-			position: relative;
-			height: 100px;
-			background-color: #4c5e6d;
-			background-size: cover;
-			background-position: center;
-			border-radius: 8px;
-
-			> .avatar {
-				position: absolute;
-				top: 60px;
-				width: 64px;
-				height: 64px;
-				left: 0;
-				right: 0;
-				margin: 0 auto;
-				border: solid 4px var(--panel);
-			}
-		}
-
-		> .title {
-			text-align: center;
-		}
-
-		> .status {
-			text-align: center;
-			margin-top: 8px;
-		}
-
-		> .rawdata {
-			overflow: auto;
-		}
-	}
-}
-</style>
diff --git a/src/client/pages/instance/user.vue b/src/client/pages/instance/user.vue
new file mode 100644
index 0000000000..fbc10a3672
--- /dev/null
+++ b/src/client/pages/instance/user.vue
@@ -0,0 +1,229 @@
+<template>
+<FormBase>
+	<FormSuspense :p="init">
+		<div class="_formItem aeakzknw">
+			<MkAvatar class="avatar" :user="user" :show-indicator="true"/>
+		</div>
+
+		<FormLink :to="userPage(user)">Profile</FormLink>
+
+		<FormGroup>
+			<FormKeyValueView>
+				<template #key>Acct</template>
+				<template #value><span class="_monospace">{{ acct(user) }}</span></template>
+			</FormKeyValueView>
+
+			<FormKeyValueView>
+				<template #key>ID</template>
+				<template #value><span class="_monospace">{{ user.id }}</span></template>
+			</FormKeyValueView>
+		</FormGroup>
+
+		<FormGroup>
+			<FormSwitch v-if="user.host == null && $i.isAdmin && (moderator || !user.isAdmin)" @update:value="toggleModerator" v-model:value="moderator">{{ $ts.moderator }}</FormSwitch>
+			<FormSwitch @update:value="toggleSilence" v-model:value="silenced">{{ $ts.silence }}</FormSwitch>
+			<FormSwitch @update:value="toggleSuspend" v-model:value="suspended">{{ $ts.suspend }}</FormSwitch>
+		</FormGroup>
+
+		<FormGroup>
+			<FormButton v-if="user.host != null" @click="updateRemoteUser"><i class="fas fa-sync"></i> {{ $ts.updateRemoteUser }}</FormButton>
+			<FormButton v-if="user.host == null" @click="resetPassword"><i class="fas fa-key"></i> {{ $ts.resetPassword }}</FormButton>
+		</FormGroup>
+
+		<FormGroup>
+			<FormLink :to="`/user-ap-info/${user.id}`">ActivityPub</FormLink>
+
+			<FormLink v-if="user.host" :to="`/instance-info/${user.host}`">{{ $ts.instanceInfo }}<template #suffix>{{ user.host }}</template></FormLink>
+			<FormKeyValueView v-else>
+				<template #key>{{ $ts.instanceInfo }}</template>
+				<template #value>(Local user)</template>
+			</FormKeyValueView>
+		</FormGroup>
+
+		<FormGroup>
+			<FormKeyValueView>
+				<template #key>{{ $ts.updatedAt }}</template>
+				<template #value><MkTime v-if="user.lastFetchedAt" mode="detail" :time="user.lastFetchedAt"/><span v-else>N/A</span></template>
+			</FormKeyValueView>
+		</FormGroup>
+
+		<FormObjectView tall :value="user">
+			<span>Raw</span>
+		</FormObjectView>
+	</FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { computed, defineAsyncComponent, defineComponent } from 'vue';
+import FormObjectView from '@client/components/form/object-view.vue';
+import FormSwitch from '@client/components/form/switch.vue';
+import FormLink from '@client/components/form/link.vue';
+import FormBase from '@client/components/form/base.vue';
+import FormGroup from '@client/components/form/group.vue';
+import FormButton from '@client/components/form/button.vue';
+import FormKeyValueView from '@client/components/form/key-value-view.vue';
+import FormSuspense from '@client/components/form/suspense.vue';
+import * as os from '@client/os';
+import number from '@client/filters/number';
+import bytes from '@client/filters/bytes';
+import * as symbols from '@client/symbols';
+import { url } from '@client/config';
+import { userPage, acct } from '@client/filters/user';
+
+export default defineComponent({
+	components: {
+		FormBase,
+		FormSwitch,
+		FormObjectView,
+		FormButton,
+		FormLink,
+		FormGroup,
+		FormKeyValueView,
+		FormSuspense,
+	},
+
+	props: {
+		userId: {
+			type: String,
+			required: true
+		}
+	},
+
+	data() {
+		return {
+			[symbols.PAGE_INFO]: computed(() => ({
+				title: this.$ts.userInfo,
+				icon: 'fas fa-info-circle',
+				actions: this.user ? [this.user.url ? {
+					text: this.user.url,
+					icon: 'fas fa-external-link-alt',
+					handler: () => {
+						window.open(this.user.url, '_blank');
+					}
+				} : undefined].filter(x => x !== undefined) : [],
+			})),
+			init: null,
+			user: null,
+			info: null,
+			moderator: false,
+			silenced: false,
+			suspended: false,
+		}
+	},
+
+	watch: {
+		userId: {
+			handler() {
+				this.init = this.createFetcher();
+			},
+			immediate: true
+		}
+	},
+
+	methods: {
+		number,
+		bytes,
+		userPage,
+		acct,
+
+		createFetcher() {
+			return () => Promise.all([os.api('users/show', {
+				userId: this.userId
+			}), os.api('admin/show-user', {
+				userId: this.userId
+			})]).then(([user, info]) => {
+				this.user = user;
+				this.info = info;
+				this.moderator = this.info.isModerator;
+				this.silenced = this.info.isSilenced;
+				this.suspended = this.info.isSuspended;
+			});
+		},
+
+		refreshUser() {
+			this.init = this.createFetcher();
+		},
+
+		async updateRemoteUser() {
+			await os.apiWithDialog('admin/update-remote-user', { userId: this.user.id });
+			this.refreshUser();
+		},
+
+		async resetPassword() {
+			os.apiWithDialog('admin/reset-password', {
+				userId: this.user.id,
+			}, undefined, ({ password }) => {
+				os.dialog({
+					type: 'success',
+					text: this.$t('newPasswordIs', { password })
+				});
+			});
+		},
+
+		async toggleSilence(v) {
+			const confirm = await os.dialog({
+				type: 'warning',
+				showCancelButton: true,
+				text: v ? this.$ts.silenceConfirm : this.$ts.unsilenceConfirm,
+			});
+			if (confirm.canceled) {
+				this.silenced = !v;
+			} else {
+				await os.api(v ? 'admin/silence-user' : 'admin/unsilence-user', { userId: this.user.id });
+				await this.refreshUser();
+			}
+		},
+
+		async toggleSuspend(v) {
+			const confirm = await os.dialog({
+				type: 'warning',
+				showCancelButton: true,
+				text: v ? this.$ts.suspendConfirm : this.$ts.unsuspendConfirm,
+			});
+			if (confirm.canceled) {
+				this.suspended = !v;
+			} else {
+				await os.api(v ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: this.user.id });
+				await this.refreshUser();
+			}
+		},
+
+		async toggleModerator(v) {
+			await os.api(v ? 'admin/moderators/add' : 'admin/moderators/remove', { userId: this.user.id });
+			await this.refreshUser();
+		},
+
+		async deleteAllFiles() {
+			const confirm = await os.dialog({
+				type: 'warning',
+				showCancelButton: true,
+				text: this.$ts.deleteAllFilesConfirm,
+			});
+			if (confirm.canceled) return;
+			const process = async () => {
+				await os.api('admin/delete-all-files-of-a-user', { userId: this.user.id });
+				os.success();
+			};
+			await process().catch(e => {
+				os.dialog({
+					type: 'error',
+					text: e.toString()
+				});
+			});
+			await this.refreshUser();
+		},
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.aeakzknw {
+	> .avatar {
+		display: block;
+		margin: 0 auto;
+		width: 64px;
+		height: 64px;
+	}
+}
+</style>
diff --git a/src/client/pages/instance/users.vue b/src/client/pages/instance/users.vue
index 4db965588c..452886abde 100644
--- a/src/client/pages/instance/users.vue
+++ b/src/client/pages/instance/users.vue
@@ -1,86 +1,71 @@
 <template>
-<div class="mk-instance-users">
-	<div class="_section">
-		<div class="_content">
-			<MkButton inline primary @click="addUser()"><i class="fas fa-plus"></i> {{ $ts.addUser }}</MkButton>
-		</div>
+<div class="lknzcolw">
+	<div class="actions">
+		<MkButton inline primary @click="addUser()"><i class="fas fa-plus"></i> {{ $ts.addUser }}</MkButton>
+		<MkButton inline primary @click="lookupUser()"><i class="fas fa-search"></i> {{ $ts.lookup }}</MkButton>
 	</div>
 
-	<div class="_section lookup">
-		<div class="_title"><i class="fas fa-search"></i> {{ $ts.lookup }}</div>
-		<div class="_content">
-			<MkInput class="target" v-model:value="target" type="text" @enter="showUser()">
-				<span>{{ $ts.usernameOrUserId }}</span>
+	<div class="users">
+		<div class="inputs" style="display: flex;">
+			<MkSelect v-model:value="sort" style="margin: 0; flex: 1;">
+				<template #label>{{ $ts.sort }}</template>
+				<option value="-createdAt">{{ $ts.registeredDate }} ({{ $ts.ascendingOrder }})</option>
+				<option value="+createdAt">{{ $ts.registeredDate }} ({{ $ts.descendingOrder }})</option>
+				<option value="-updatedAt">{{ $ts.lastUsed }} ({{ $ts.ascendingOrder }})</option>
+				<option value="+updatedAt">{{ $ts.lastUsed }} ({{ $ts.descendingOrder }})</option>
+			</MkSelect>
+			<MkSelect v-model:value="state" style="margin: 0; flex: 1;">
+				<template #label>{{ $ts.state }}</template>
+				<option value="all">{{ $ts.all }}</option>
+				<option value="available">{{ $ts.normal }}</option>
+				<option value="admin">{{ $ts.administrator }}</option>
+				<option value="moderator">{{ $ts.moderator }}</option>
+				<option value="silenced">{{ $ts.silence }}</option>
+				<option value="suspended">{{ $ts.suspend }}</option>
+			</MkSelect>
+			<MkSelect v-model:value="origin" style="margin: 0; flex: 1;">
+				<template #label>{{ $ts.instance }}</template>
+				<option value="combined">{{ $ts.all }}</option>
+				<option value="local">{{ $ts.local }}</option>
+				<option value="remote">{{ $ts.remote }}</option>
+			</MkSelect>
+		</div>
+		<div class="inputs" style="display: flex; padding-top: 1.2em;">
+			<MkInput v-model:value="searchUsername" style="margin: 0; flex: 1;" type="text" spellcheck="false" @update:value="$refs.users.reload()">
+				<span>{{ $ts.username }}</span>
+			</MkInput>
+			<MkInput v-model:value="searchHost" style="margin: 0; flex: 1;" type="text" spellcheck="false" @update:value="$refs.users.reload()" :disabled="pagination.params().origin === 'local'">
+				<span>{{ $ts.host }}</span>
 			</MkInput>
-			<MkButton @click="showUser()" primary><i class="fas fa-search"></i> {{ $ts.lookup }}</MkButton>
 		</div>
-	</div>
 
-	<div class="_section users">
-		<div class="_title"><i class="fas fa-users"></i> {{ $ts.users }}</div>
-		<div class="_content">
-			<div class="inputs" style="display: flex;">
-				<MkSelect v-model:value="sort" style="margin: 0; flex: 1;">
-					<template #label>{{ $ts.sort }}</template>
-					<option value="-createdAt">{{ $ts.registeredDate }} ({{ $ts.ascendingOrder }})</option>
-					<option value="+createdAt">{{ $ts.registeredDate }} ({{ $ts.descendingOrder }})</option>
-					<option value="-updatedAt">{{ $ts.lastUsed }} ({{ $ts.ascendingOrder }})</option>
-					<option value="+updatedAt">{{ $ts.lastUsed }} ({{ $ts.descendingOrder }})</option>
-				</MkSelect>
-				<MkSelect v-model:value="state" style="margin: 0; flex: 1;">
-					<template #label>{{ $ts.state }}</template>
-					<option value="all">{{ $ts.all }}</option>
-					<option value="available">{{ $ts.normal }}</option>
-					<option value="admin">{{ $ts.administrator }}</option>
-					<option value="moderator">{{ $ts.moderator }}</option>
-					<option value="silenced">{{ $ts.silence }}</option>
-					<option value="suspended">{{ $ts.suspend }}</option>
-				</MkSelect>
-				<MkSelect v-model:value="origin" style="margin: 0; flex: 1;">
-					<template #label>{{ $ts.instance }}</template>
-					<option value="combined">{{ $ts.all }}</option>
-					<option value="local">{{ $ts.local }}</option>
-					<option value="remote">{{ $ts.remote }}</option>
-				</MkSelect>
-			</div>
-			<div class="inputs" style="display: flex; padding-top: 1.2em;">
-				<MkInput v-model:value="searchUsername" style="margin: 0; flex: 1;" type="text" spellcheck="false" @update:value="$refs.users.reload()">
-					<span>{{ $ts.username }}</span>
-				</MkInput>
-				<MkInput v-model:value="searchHost" style="margin: 0; flex: 1;" type="text" spellcheck="false" @update:value="$refs.users.reload()" :disabled="pagination.params().origin === 'local'">
-					<span>{{ $ts.host }}</span>
-				</MkInput>
-			</div>
-
-			<MkPagination :pagination="pagination" #default="{items}" class="users" ref="users">
-				<button class="user _panel _button _gap" v-for="user in items" :key="user.id" @click="show(user)">
-					<MkAvatar class="avatar" :user="user" :disable-link="true" :show-indicator="true"/>
-					<div class="body">
-						<header>
-							<MkUserName class="name" :user="user"/>
-							<span class="acct">@{{ acct(user) }}</span>
-							<span class="staff" v-if="user.isAdmin"><i class="fas fa-bookmark"></i></span>
-							<span class="staff" v-if="user.isModerator"><i class="far fa-bookmark"></i></span>
-							<span class="punished" v-if="user.isSilenced"><i class="fas fa-microphone-slash"></i></span>
-							<span class="punished" v-if="user.isSuspended"><i class="fas fa-snowflake"></i></span>
-						</header>
-						<div>
-							<span>{{ $ts.lastUsed }}: <MkTime v-if="user.updatedAt" :time="user.updatedAt" mode="detail"/></span>
-						</div>
-						<div>
-							<span>{{ $ts.registeredDate }}: <MkTime :time="user.createdAt" mode="detail"/></span>
-						</div>
+		<MkPagination :pagination="pagination" #default="{items}" class="users" ref="users">
+			<button class="user _panel _button _gap" v-for="user in items" :key="user.id" @click="show(user)">
+				<MkAvatar class="avatar" :user="user" :disable-link="true" :show-indicator="true"/>
+				<div class="body">
+					<header>
+						<MkUserName class="name" :user="user"/>
+						<span class="acct">@{{ acct(user) }}</span>
+						<span class="staff" v-if="user.isAdmin"><i class="fas fa-bookmark"></i></span>
+						<span class="staff" v-if="user.isModerator"><i class="far fa-bookmark"></i></span>
+						<span class="punished" v-if="user.isSilenced"><i class="fas fa-microphone-slash"></i></span>
+						<span class="punished" v-if="user.isSuspended"><i class="fas fa-snowflake"></i></span>
+					</header>
+					<div>
+						<span>{{ $ts.lastUsed }}: <MkTime v-if="user.updatedAt" :time="user.updatedAt" mode="detail"/></span>
 					</div>
-				</button>
-			</MkPagination>
-		</div>
+					<div>
+						<span>{{ $ts.registeredDate }}: <MkTime :time="user.createdAt" mode="detail"/></span>
+					</div>
+				</div>
+			</button>
+		</MkPagination>
 	</div>
 </div>
 </template>
 
 <script lang="ts">
 import { defineComponent } from 'vue';
-import parseAcct from '@/misc/acct/parse';
 import MkButton from '@client/components/ui/button.vue';
 import MkInput from '@client/components/ui/input.vue';
 import MkSelect from '@client/components/ui/select.vue';
@@ -88,6 +73,7 @@ import MkPagination from '@client/components/ui/pagination.vue';
 import { acct } from '../../filters/user';
 import * as os from '@client/os';
 import * as symbols from '@client/symbols';
+import { lookupUser } from '@client/scripts/lookup-user';
 
 export default defineComponent({
 	components: {
@@ -97,6 +83,8 @@ export default defineComponent({
 		MkPagination,
 	},
 
+	emits: ['info'],
+
 	data() {
 		return {
 			[symbols.PAGE_INFO]: {
@@ -107,7 +95,6 @@ export default defineComponent({
 					handler: this.searchUser
 				}
 			},
-			target: '',
 			sort: '+createdAt',
 			state: 'all',
 			origin: 'local',
@@ -140,40 +127,12 @@ export default defineComponent({
 		},
 	},
 
-	methods: {
-		/** テキストエリアのユーザーを解決する */
-		fetchUser() {
-			return new Promise((res) => {
-				const usernamePromise = os.api('users/show', parseAcct(this.target));
-				const idPromise = os.api('users/show', { userId: this.target });
-				let _notFound = false;
-				const notFound = () => {
-					if (_notFound) {
-						os.dialog({
-							type: 'error',
-							text: this.$ts.noSuchUser
-						});
-					} else {
-						_notFound = true;
-					}
-				};
-				usernamePromise.then(res).catch(e => {
-					if (e.code === 'NO_SUCH_USER') {
-						notFound();
-					}
-				});
-				idPromise.then(res).catch(e => {
-					notFound();
-				});
-			});
-		},
+	async mounted() {
+		this.$emit('info', this[symbols.PAGE_INFO]);
+	},
 
-		/** テキストエリアから処理対象ユーザーを設定する */
-		async showUser() {
-			const user = await this.fetchUser();
-			this.show(user);
-			this.target = '';
-		},
+	methods: {
+		lookupUser,
 
 		searchUser() {
 			os.selectUser().then(user => {
@@ -203,9 +162,7 @@ export default defineComponent({
 		},
 
 		show(user) {
-			os.popup(import('./user-dialog.vue'), {
-				userId: user.id
-			}, {}, 'closed');
+			os.pageWindow(`/instance/user/${user.id}`);
 		},
 
 		acct
@@ -214,57 +171,61 @@ export default defineComponent({
 </script>
 
 <style lang="scss" scoped>
-.mk-instance-users {
+.lknzcolw {
+	> .actions {
+		margin: var(--margin);
+	}
+
 	> .users {
-		> ._content {
-			> .users {
-				margin-top: var(--margin);
+		margin: var(--margin);
+	
+		> .users {
+			margin-top: var(--margin);
+
+			> .user {
+				display: flex;
+				width: 100%;
+				box-sizing: border-box;
+				text-align: left;
+				align-items: center;
+				padding: 16px;
+
+				&:hover {
+					color: var(--accent);
+				}
 
-				> .user {
-					display: flex;
-					width: 100%;
-					box-sizing: border-box;
-					text-align: left;
-					align-items: center;
-					padding: 16px;
+				> .avatar {
+					width: 60px;
+					height: 60px;
+				}
 
-					&:hover {
-						color: var(--accent);
-					}
+				> .body {
+					margin-left: 0.3em;
+					padding: 0 8px;
+					flex: 1;
 
-					> .avatar {
-						width: 60px;
-						height: 60px;
+					@media (max-width: 500px) {
+						font-size: 14px;
 					}
 
-					> .body {
-						margin-left: 0.3em;
-						padding: 0 8px;
-						flex: 1;
-
-						@media (max-width: 500px) {
-							font-size: 14px;
+					> header {
+						> .name {
+							font-weight: bold;
 						}
 
-						> header {
-							> .name {
-								font-weight: bold;
-							}
-
-							> .acct {
-								margin-left: 8px;
-								opacity: 0.7;
-							}
+						> .acct {
+							margin-left: 8px;
+							opacity: 0.7;
+						}
 
-							> .staff {
-								margin-left: 0.5em;
-								color: var(--badge);
-							}
+						> .staff {
+							margin-left: 0.5em;
+							color: var(--badge);
+						}
 
-							> .punished {
-								margin-left: 0.5em;
-								color: #4dabf7;
-							}
+						> .punished {
+							margin-left: 0.5em;
+							color: #4dabf7;
 						}
 					}
 				}
diff --git a/src/client/pages/user-info.vue b/src/client/pages/user-info.vue
index ebe462f796..378fbb7b50 100644
--- a/src/client/pages/user-info.vue
+++ b/src/client/pages/user-info.vue
@@ -1,34 +1,36 @@
 <template>
 <FormBase>
-	<FormGroup v-if="user">
-		<template #label><MkAcct :user="user"/></template>
-
-		<FormKeyValueView>
-			<template #key>ID</template>
-			<template #value><span class="_monospace">{{ user.id }}</span></template>
-		</FormKeyValueView>
-
+	<FormSuspense :p="init">
 		<FormGroup>
-			<FormLink :to="`/user-ap-info/${user.id}`">ActivityPub</FormLink>
-
-			<FormLink v-if="user.host" :to="`/instance-info/${user.host}`">{{ $ts.instanceInfo }}<template #suffix>{{ user.host }}</template></FormLink>
-			<FormKeyValueView v-else>
-				<template #key>{{ $ts.instanceInfo }}</template>
-				<template #value>(Local user)</template>
-			</FormKeyValueView>
-		</FormGroup>
+			<template #label><MkAcct :user="user"/></template>
 
-		<FormGroup>
 			<FormKeyValueView>
-				<template #key>{{ $ts.updatedAt }}</template>
-				<template #value><MkTime v-if="user.lastFetchedAt" mode="detail" :time="user.lastFetchedAt"/><span v-else>N/A</span></template>
+				<template #key>ID</template>
+				<template #value><span class="_monospace">{{ user.id }}</span></template>
 			</FormKeyValueView>
-		</FormGroup>
 
-		<FormObjectView tall :value="user">
-			<span>Raw</span>
-		</FormObjectView>
-	</FormGroup>
+			<FormGroup>
+				<FormLink :to="`/user-ap-info/${user.id}`">ActivityPub</FormLink>
+
+				<FormLink v-if="user.host" :to="`/instance-info/${user.host}`">{{ $ts.instanceInfo }}<template #suffix>{{ user.host }}</template></FormLink>
+				<FormKeyValueView v-else>
+					<template #key>{{ $ts.instanceInfo }}</template>
+					<template #value>(Local user)</template>
+				</FormKeyValueView>
+			</FormGroup>
+
+			<FormGroup>
+				<FormKeyValueView>
+					<template #key>{{ $ts.updatedAt }}</template>
+					<template #value><MkTime v-if="user.lastFetchedAt" mode="detail" :time="user.lastFetchedAt"/><span v-else>N/A</span></template>
+				</FormKeyValueView>
+			</FormGroup>
+
+			<FormObjectView tall :value="user">
+				<span>Raw</span>
+			</FormObjectView>
+		</FormGroup>
+	</FormSuspense>
 </FormBase>
 </template>
 
@@ -80,23 +82,27 @@ export default defineComponent({
 					}
 				} : undefined].filter(x => x !== undefined) : [],
 			})),
+			init: null,
 			user: null,
 		}
 	},
 
-	mounted() {
-		this.fetch();
+	watch: {
+		userId: {
+			handler() {
+				this.init = () => os.api('users/show', {
+					userId: this.userId
+				}).then(user => {
+					this.user = user;
+				});
+			},
+			immediate: true
+		}
 	},
 
 	methods: {
 		number,
 		bytes,
-
-		async fetch() {
-			this.user = await os.api('users/show', {
-				userId: this.userId
-			});
-		}
 	}
 });
 </script>
diff --git a/src/client/pages/user/index.vue b/src/client/pages/user/index.vue
index 477f235062..207b44f631 100644
--- a/src/client/pages/user/index.vue
+++ b/src/client/pages/user/index.vue
@@ -195,7 +195,7 @@
 
 			<template v-if="page === 'index'">
 				<div>
-					<div v-if="user.pinnedNotes.length > 0">
+					<div v-if="user.pinnedNotes.length > 0" class="_gap">
 						<XNote v-for="note in user.pinnedNotes" class="note _block" :note="note" @update:note="pinnedNoteUpdated(note, $event)" :key="note.id" :pinned="true"/>
 					</div>
 					<MkInfo v-else-if="$i && $i.id === user.id">{{ $ts.userPagePinTip }}</MkInfo>
diff --git a/src/client/router.ts b/src/client/router.ts
index bf45c806e2..93de287ea5 100644
--- a/src/client/router.ts
+++ b/src/client/router.ts
@@ -59,17 +59,9 @@ export const router = createRouter({
 		{ path: '/my/antennas', component: page('my-antennas/index') },
 		{ path: '/my/clips', component: page('my-clips/index') },
 		{ path: '/scratchpad', component: page('scratchpad') },
+		{ path: '/instance/user/:user', component: page('instance/user'), props: route => ({ userId: route.params.user }) },
+		{ path: '/instance/:page(.*)?', component: page('instance/index'), props: route => ({ initialPage: route.params.page || null }) },
 		{ path: '/instance', component: page('instance/index') },
-		{ path: '/instance/emojis', component: page('instance/emojis') },
-		{ path: '/instance/users', component: page('instance/users') },
-		{ path: '/instance/logs', component: page('instance/logs') },
-		{ path: '/instance/files', component: page('instance/files') },
-		{ path: '/instance/queue', component: page('instance/queue') },
-		{ path: '/instance/settings', component: page('instance/settings') },
-		{ path: '/instance/federation', component: page('instance/federation') },
-		{ path: '/instance/relays', component: page('instance/relays') },
-		{ path: '/instance/announcements', component: page('instance/announcements') },
-		{ path: '/instance/abuses', component: page('instance/abuses') },
 		{ path: '/notes/:note', name: 'note', component: page('note'), props: route => ({ noteId: route.params.note }) },
 		{ path: '/tags/:tag', component: page('tag'), props: route => ({ tag: route.params.tag }) },
 		{ path: '/user-info/:user', component: page('user-info'), props: route => ({ userId: route.params.user }) },
diff --git a/src/client/scripts/get-user-menu.ts b/src/client/scripts/get-user-menu.ts
index ceb2bfe173..9a003b5c38 100644
--- a/src/client/scripts/get-user-menu.ts
+++ b/src/client/scripts/get-user-menu.ts
@@ -124,7 +124,13 @@ export function getUserMenu(user) {
 		action: () => {
 			copyToClipboard(`@${user.username}@${user.host || host}`);
 		}
-	}, {
+	}, ($i && ($i.isAdmin || $i.isModerator)) ? {
+		icon: 'fas fa-info-circle',
+		text: i18n.locale.info,
+		action: () => {
+			os.pageWindow(`/instance/user/${user.id}`);
+		}
+	} : {
 		icon: 'fas fa-info-circle',
 		text: i18n.locale.info,
 		action: () => {
diff --git a/src/client/scripts/lookup-user.ts b/src/client/scripts/lookup-user.ts
new file mode 100644
index 0000000000..1bcfd8e9db
--- /dev/null
+++ b/src/client/scripts/lookup-user.ts
@@ -0,0 +1,37 @@
+import parseAcct from '@/misc/acct/parse';
+import { i18n } from '@client/i18n';
+import * as os from '@client/os';
+
+export async function lookupUser() {
+	const { canceled, result } = await os.dialog({
+		title: i18n.locale.usernameOrUserId,
+		input: true
+	});
+	if (canceled) return;
+
+	const show = (user) => {
+		os.pageWindow(`/instance/user/${user.id}`);
+	};
+
+	const usernamePromise = os.api('users/show', parseAcct(result));
+	const idPromise = os.api('users/show', { userId: result });
+	let _notFound = false;
+	const notFound = () => {
+		if (_notFound) {
+			os.dialog({
+				type: 'error',
+				text: i18n.locale.noSuchUser
+			});
+		} else {
+			_notFound = true;
+		}
+	};
+	usernamePromise.then(show).catch(e => {
+		if (e.code === 'NO_SUCH_USER') {
+			notFound();
+		}
+	});
+	idPromise.then(show).catch(e => {
+		notFound();
+	});
+}
diff --git a/src/client/ui/_common_/sidebar.vue b/src/client/ui/_common_/sidebar.vue
index fefe6afa7b..45b1c079bd 100644
--- a/src/client/ui/_common_/sidebar.vue
+++ b/src/client/ui/_common_/sidebar.vue
@@ -25,9 +25,9 @@
 					</component>
 				</template>
 				<div class="divider"></div>
-				<button class="item _button" :class="{ active: $route.path === '/instance' || $route.path.startsWith('/instance/') }" v-if="$i.isAdmin || $i.isModerator" @click="oepnInstanceMenu">
+				<MkA v-if="$i.isAdmin || $i.isModerator" class="item" active-class="active" to="/instance">
 					<i class="fas fa-server fa-fw"></i><span class="text">{{ $ts.instance }}</span>
-				</button>
+				</MkA>
 				<button class="item _button" @click="more">
 					<i class="fa fa-ellipsis-h fa-fw"></i><span class="text">{{ $ts.more }}</span>
 					<span v-if="otherNavItemIndicated" class="indicator"><i class="fas fa-circle"></i></span>
@@ -172,65 +172,6 @@ export default defineComponent({
 			});
 		},
 
-		oepnInstanceMenu(ev) {
-			os.modalMenu([{
-				type: 'link',
-				text: this.$ts.dashboard,
-				to: '/instance',
-				icon: 'fas fa-tachometer-alt',
-			}, null, this.$i.isAdmin ? {
-				type: 'link',
-				text: this.$ts.settings,
-				to: '/instance/settings',
-				icon: 'fas fa-cog',
-			} : undefined, {
-				type: 'link',
-				text: this.$ts.customEmojis,
-				to: '/instance/emojis',
-				icon: 'fas fa-laugh',
-			}, {
-				type: 'link',
-				text: this.$ts.users,
-				to: '/instance/users',
-				icon: 'fas fa-users',
-			}, {
-				type: 'link',
-				text: this.$ts.files,
-				to: '/instance/files',
-				icon: 'fas fa-cloud',
-			}, {
-				type: 'link',
-				text: this.$ts.jobQueue,
-				to: '/instance/queue',
-				icon: 'fas fa-exchange-alt',
-			}, {
-				type: 'link',
-				text: this.$ts.federation,
-				to: '/instance/federation',
-				icon: 'fas fa-globe',
-			}, {
-				type: 'link',
-				text: this.$ts.relays,
-				to: '/instance/relays',
-				icon: 'fas fa-project-diagram',
-			}, {
-				type: 'link',
-				text: this.$ts.announcements,
-				to: '/instance/announcements',
-				icon: 'fas fa-broadcast-tower',
-			}, {
-				type: 'link',
-				text: this.$ts.abuseReports,
-				to: '/instance/abuses',
-				icon: 'fas fa-exclamation-circle',
-			}, {
-				type: 'link',
-				text: this.$ts.logs,
-				to: '/instance/logs',
-				icon: 'fas fa-stream',
-			}], ev.currentTarget || ev.target);
-		},
-
 		more(ev) {
 			os.popup(import('@client/components/launch-pad.vue'), {}, {
 			}, 'closed');
diff --git a/src/client/ui/default.sidebar.vue b/src/client/ui/default.sidebar.vue
index 952ec3903d..29ef99fc86 100644
--- a/src/client/ui/default.sidebar.vue
+++ b/src/client/ui/default.sidebar.vue
@@ -20,9 +20,9 @@
 		</component>
 	</template>
 	<div class="divider"></div>
-	<button class="item _button" :class="{ active: $route.path === '/instance' || $route.path.startsWith('/instance/') }" v-if="$i.isAdmin || $i.isModerator" @click="oepnInstanceMenu">
+	<MkA v-if="$i.isAdmin || $i.isModerator" class="item" active-class="active" to="/instance" :behavior="settingsWindowed ? 'modalWindow' : null">
 		<i class="fas fa-server fa-fw"></i><span class="text">{{ $ts.instance }}</span>
-	</button>
+	</MkA>
 	<button class="item _button" @click="more">
 		<i class="fas fa-ellipsis-h fa-fw"></i><span class="text">{{ $ts.more }}</span>
 		<span v-if="otherNavItemIndicated" class="indicator"><i class="fas fa-circle"></i></span>
@@ -156,65 +156,6 @@ export default defineComponent({
 			});
 		},
 
-		oepnInstanceMenu(ev) {
-			os.modalMenu([{
-				type: 'link',
-				text: this.$ts.dashboard,
-				to: '/instance',
-				icon: 'fas fa-tachometer-alt',
-			}, null, this.$i.isAdmin ? {
-				type: 'link',
-				text: this.$ts.settings,
-				to: '/instance/settings',
-				icon: 'fas fa-cog',
-			} : undefined, {
-				type: 'link',
-				text: this.$ts.customEmojis,
-				to: '/instance/emojis',
-				icon: 'fas fa-laugh',
-			}, {
-				type: 'link',
-				text: this.$ts.users,
-				to: '/instance/users',
-				icon: 'fas fa-users',
-			}, {
-				type: 'link',
-				text: this.$ts.files,
-				to: '/instance/files',
-				icon: 'fas fa-cloud',
-			}, {
-				type: 'link',
-				text: this.$ts.jobQueue,
-				to: '/instance/queue',
-				icon: 'fas fa-exchange-alt',
-			}, {
-				type: 'link',
-				text: this.$ts.federation,
-				to: '/instance/federation',
-				icon: 'fas fa-globe',
-			}, {
-				type: 'link',
-				text: this.$ts.relays,
-				to: '/instance/relays',
-				icon: 'fas fa-project-diagram',
-			}, {
-				type: 'link',
-				text: this.$ts.announcements,
-				to: '/instance/announcements',
-				icon: 'fas fa-broadcast-tower',
-			}, {
-				type: 'link',
-				text: this.$ts.abuseReports,
-				to: '/instance/abuses',
-				icon: 'fas fa-exclamation-circle',
-			}, {
-				type: 'link',
-				text: this.$ts.logs,
-				to: '/instance/logs',
-				icon: 'fas fa-stream',
-			}], ev.currentTarget || ev.target);
-		},
-
 		more(ev) {
 			os.popup(import('@client/components/launch-pad.vue'), {}, {
 			}, 'closed');
-- 
cgit v1.2.3-freya

