diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3bd675deae..dcb67470b1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,10 +13,12 @@
 ## 12.x.x (unreleased)
 
 ### Improvements
+- クライアント: コントロールパネルのパフォーマンスを改善
 - クライアント: 自分のリアクション一覧を見れるように
 	- 設定により、リアクション一覧を全員に公開することも可能
 - クライアント: ユーザー検索の精度を強化
 - クライアント: 新しいライトテーマを追加
+- クライアント: 新しいダークテーマを追加
 - API: ユーザーのリアクション一覧を取得する users/reactions を追加
 - API: users/search および users/search-by-username-and-host を強化
 - ミュート及びブロックのインポートを行えるように
@@ -25,6 +27,7 @@
 ### Bugfixes
 - クライアント: テーマの管理が行えない問題を修正
 - API: アプリケーション通知が取得できない問題を修正
+- クライアント: リモートノートで意図せずローカルカスタム絵文字が使われてしまうことがあるのを修正
 - ActivityPub: not reacted な Undo.Like がinboxに滞留するのを修正
 
 ## 12.92.0 (2021/10/16)
diff --git a/README.md b/README.md
index 0aae1b3329..ce0aa09417 100644
--- a/README.md
+++ b/README.md
@@ -65,7 +65,7 @@ Organize and store your files! Want to post a picture you have already uploaded?
 
 :package: Create your own instance
 ----------------------------------------------------------------
-Please see the [Setup and Installation Guide](./docs/setup.en.md).
+Please see the [Setup and Installation Guide](https://misskey-hub.net/docs/install/install.html).
 
 :wrench: Contribution
 ----------------------------------------------------------------
diff --git a/docs/README.md b/docs/README.md
deleted file mode 100644
index 87b100772f..0000000000
--- a/docs/README.md
+++ /dev/null
@@ -1,33 +0,0 @@
-# Docs
-These docs are for contributors of Misskey or admins of instance of Misskey.
-Docs for users are located in `src/docs`.
-
-これらのドキュメントはMisskeyの開発者またはMisskeyインスタンス運営者向けです。
-利用者向けのドキュメントは`src/docs`にあります。
-
-这些文档是为 Misskey 的贡献者,或是 Misskey 实例的管理者准备的。
-为用户准备的文档放置在 `src/docs` 文件夹中。
-
-## 日本語版
-
-- [Misskey構築の手引き](./setup.ja.md)
-- [運営ガイド](./manage.ja.md)
-- [Dockerを使ったMisskey構築方法](./docker.ja.md)
-
-## English Version
-
-- [Misskey Setup and Installation Guide](./setup.en.md)
-- [Management guide](./manage.en.md)
-- [Docker Guide](./docker.en.md)
-
-## Française Version
-
-- [Guide d'installation et de configuration de Misskey](./setup.fr.md)
-- [Guide d'administration](./manage.fr.md)
-- [Guide Docker](./docker.fr.md)
-
-## 简体中文版
-
-- [Misskey 设置和安装指南](./setup.zh.md)
-- [运营指南](./manage.zh.md)
-- [Docker 部署指南](./docker.zh.md)
diff --git a/docs/docker.en.md b/docs/docker.en.md
deleted file mode 100644
index adeafe3d31..0000000000
--- a/docs/docker.en.md
+++ /dev/null
@@ -1,97 +0,0 @@
-Docker Guide
-================================================================
-
-This guide describes how to install and setup Misskey with Docker.
-
-- [Japanese version also available - 日本語版もあります](./docker.ja.md)
-- [Simplified Chinese version also available - 简体中文版同样可用](./docker.zh.md)
-
-----------------------------------------------------------------
-
-*1.* Download Misskey
-----------------------------------------------------------------
-1. Clone Misskey repository's master branch.
-
-	`git clone -b master git://github.com/misskey-dev/misskey.git`
-
-2. Move to misskey directory.
-
-	`cd misskey`
-
-3. Checkout to the [latest release](https://github.com/misskey-dev/misskey/releases/latest) tag.
-
-	`git checkout master`
-
-*2.* Configure Misskey
-----------------------------------------------------------------
-
-Create configuration files with following:
-
-```bash
-cd .config
-cp example.yml default.yml
-cp docker_example.env docker.env
-```
-
-### `default.yml`
-
-Edit this file the same as non-Docker environment.  
-However hostname of Postgresql, Redis and Elasticsearch are not `localhost`, they are set in `docker-compose.yml`.  
-The following is default hostname:
-
-| Service       | Hostname |
-|---------------|----------|
-| Postgresql    | `db`     |
-| Redis         | `redis`  |
-| Elasticsearch | `es`     |
-
-### `docker.env`
-
-Configure Postgresql in this file.  
-The minimum required settings are:
-
-| name                | Description   |
-|---------------------|---------------|
-| `POSTGRES_PASSWORD` | Password      |
-| `POSTGRES_USER`     | Username      |
-| `POSTGRES_DB`       | Database name |
-
-*3.* Configure Docker
-----------------------------------------------------------------
-Edit `docker-compose.yml`.
-
-*4.* Build Misskey
-----------------------------------------------------------------
-Build misskey with the following:
-
-`docker-compose build`
-
-*5.* Init DB
-----------------------------------------------------------------
-``` shell
-docker-compose run --rm web yarn run init
-```
-
-*6.* That is it.
-----------------------------------------------------------------
-Well done! Now you have an environment to run Misskey.
-
-### Launch normally
-Just `docker-compose up -d`. GLHF!
-
-### How to update your Misskey server to the latest version
-1. `git stash`
-2. `git checkout master`
-3. `git pull`
-4. `git submodule update --init`
-5. `git stash pop`
-6. `docker-compose build`
-7. Check [ChangeLog](../CHANGELOG.md) for migration information
-8. `docker-compose stop && docker-compose up -d`
-
-### How to execute [cli commands](manage.en.md):
-`docker-compose run --rm web node built/tools/mark-admin @example`
-
-----------------------------------------------------------------
-
-If you have any questions or trouble, feel free to contact us!
diff --git a/docs/docker.fr.md b/docs/docker.fr.md
deleted file mode 100644
index 840e5b5a28..0000000000
--- a/docs/docker.fr.md
+++ /dev/null
@@ -1,91 +0,0 @@
-Guide Docker
-================================================================
-
-Ce guide explique comment installer et configurer Misskey avec Docker.
-
-- [Version japonaise également disponible - Japanese version also available - 日本語版もあります](./docker.ja.md)  
-- [Version anglaise également disponible - English version also available - 英語版もあります](./docker.en.md)
-- [Version Chinois simplifié également disponible - Simplified Chinese version also available - 简体中文版同样可用](./docker.zh.md)
-
-----------------------------------------------------------------
-
-*1.* Télécharger Misskey
-----------------------------------------------------------------
-1. Clone le dépôt de Misskey sur la branche master.
-
-	`git clone -b master git://github.com/misskey-dev/misskey.git`
-
-2. Naviguez dans le dossier du dépôt.
-
-	`cd misskey`
-
-3. Checkout sur le tag de la [dernière version](https://github.com/misskey-dev/misskey/releases/latest).
-
-	`git checkout master`
-
-*2.* Configuration de Misskey
-----------------------------------------------------------------
-1. `cp .config/example.yml .config/default.yml` Copiez le fichier `.config/example.yml` et renommez-le `default.yml`.
-2. `cp .config/mongo_initdb_example.js .config/mongo_initdb.js` Copie le fichier `.config/mongo_initdb_example.js` et le renomme en `mongo_initdb.js`.
-3. Editez `default.yml` et `mongo_initdb.js`.
-
-*3.* Configurer Docker
-----------------------------------------------------------------
-Editez `docker-compose.yml`.
-
-*4.* Contruire Misskey
-----------------------------------------------------------------
-Contruire l'image Docker avec:
-
-`docker-compose build`
-
-*5.* C'est tout !
-----------------------------------------------------------------
-Parfait, Vous avez un environnement prêt pour démarrer Misskey.
-
-### Lancer normalement
-Utilisez la commande `docker-compose up -d`. GLHF!
-
-### How to update your Misskey server to the latest version
-1. `git stash`
-2. `git checkout master`
-3. `git pull`
-4. `git submodule update --init`
-5. `git stash pop`
-6. `docker-compose build`
-7. Consultez le [ChangeLog](../CHANGELOG.md) pour avoir les éventuelles informations de migration
-8. `docker-compose stop && docker-compose up -d`
-
-### Comment exécuter des [commandes](manage.fr.md)
-`docker-compose run --rm web node built/tools/mark-admin @example`
-
-### Configuration d'ElasticSearch (pour la fonction de recherche)
-*1.* Préparation de l'environnement
-----------------------------------------------------------------
-1. Permet de créer le dossier d'accueil de la base ElasticSearch aves les bons droits
-
-	`mkdir elasticsearch && chown 1000:1000 elasticsearch`
-
-2. Augmente la valeur max du paramètre map_count du système (valeur minimum pour pouvoir lancer ES)
-
-	`sysctl -w vm.max_map_count=262144`
-
-*2.* Après lancement du docker-compose, initialisation de la base ElasticSearch
-----------------------------------------------------------------
-1. Connexion dans le conteneur web
-
-	`docker-compose -it web /bin/sh`
-
-2. Ajout du paquet curl
-
-	`apk add curl`
-
-3. Création de la base ES
-
-	`curl -X PUT "es:9200/misskey" -H 'Content-Type: application/json' -d'{ "settings" : { "index" : { } }}'`
-
-4. `exit`
-
-----------------------------------------------------------------
-
-Si vous avez des questions ou des problèmes, n'hésitez pas à nous contacter !
diff --git a/docs/docker.ja.md b/docs/docker.ja.md
deleted file mode 100644
index c660a9041b..0000000000
--- a/docs/docker.ja.md
+++ /dev/null
@@ -1,98 +0,0 @@
-Dockerを使ったMisskey構築方法
-================================================================
-
-このガイドはDockerを使ったMisskeyセットアップ方法について解説します。
-
-- [英語版もあります - English version also available](./docker.en.md)
-- [简体中文版同样可用 - Simplified Chinese version also available](./docker.zh.md)
-
-----------------------------------------------------------------
-
-*1.* Misskeyのダウンロード
-----------------------------------------------------------------
-1. masterブランチからMisskeyレポジトリをクローン
-
-	`git clone -b master git://github.com/misskey-dev/misskey.git`
-
-2. misskeyディレクトリに移動
-
-	`cd misskey`
-
-3. [最新のリリース](https://github.com/misskey-dev/misskey/releases/latest)を確認
-
-	`git checkout master`
-
-*2.* 設定ファイルの作成と編集
-----------------------------------------------------------------
-
-下記コマンドで設定ファイルを作成してください。
-
-```bash
-cd .config
-cp example.yml default.yml
-cp docker_example.env docker.env
-```
-
-### `default.yml`の編集
-
-非Docker環境と同じ様に編集してください。  
-ただし、Postgresql、RedisとElasticsearchのホストは`localhost`ではなく、`docker-compose.yml`で設定されたサービス名になっています。  
-標準設定では次の通りです。
-
-| サービス       | ホスト名 |
-|---------------|---------|
-| Postgresql    |`db`     |
-| Redis         |`redis`  |
-| Elasticsearch |`es`     |
-
-### `docker.env`の編集
-
-このファイルはPostgresqlの設定を記述します。  
-最低限記述する必要がある設定は次の通りです。
-
-| 設定                 | 内容         |
-|---------------------|--------------|
-| `POSTGRES_PASSWORD` | パスワード    |
-| `POSTGRES_USER`     | ユーザー名    |
-| `POSTGRES_DB`       | データベース名 |
-
-*3.* Dockerの設定
-----------------------------------------------------------------
-`docker-compose.yml`を編集してください。
-
-*4.* Misskeyのビルド
-----------------------------------------------------------------
-次のコマンドでMisskeyをビルドしてください:
-
-`docker-compose build`
-
-*5.* データベースを初期化
-----------------------------------------------------------------
-``` shell
-docker-compose run --rm web yarn run init
-```
-
-*6.* 以上です!
-----------------------------------------------------------------
-お疲れ様でした。これでMisskeyを動かす準備は整いました。
-
-### 通常起動
-`docker-compose up -d`するだけです。GLHF!
-
-### Misskeyを最新バージョンにアップデートする方法:
-1. `git stash`
-2. `git checkout master`
-3. `git pull`
-4. `git submodule update --init`
-5. `git stash pop`
-6. `docker-compose build`
-7. [ChangeLog](../CHANGELOG.md)でマイグレーション情報を確認する
-8. `docker-compose stop && docker-compose up -d`
-
-### cliコマンドを実行する方法:
-
-`docker-compose run --rm web node built/tools/mark-admin @example`
-
-----------------------------------------------------------------
-
-なにかお困りのことがありましたらお気軽にご連絡ください。
diff --git a/docs/docker.zh.md b/docs/docker.zh.md
deleted file mode 100644
index 5a494ea11e..0000000000
--- a/docs/docker.zh.md
+++ /dev/null
@@ -1,97 +0,0 @@
-Docker 部署指南
-================================================================
-
-这份指南描述了如何使用Docker安装并设置 Misskey 。
-
-- [日本語版もあります - Japanese version also available](./docker.ja.md)
-- [英語版もあります - English version also available](./docker.en.md)
-
-----------------------------------------------------------------
-
-*1.* 下载 Misskey
-----------------------------------------------------------------
-1. 克隆 Misskey 项目的 master 分支。
-
-	`git clone -b master git://github.com/misskey-dev/misskey.git`
-
-2. 进入 misskey 文件夹。
-
-	`cd misskey`
-
-3. 检查 [最新发布版](https://github.com/misskey-dev/misskey/releases/latest) 标签。
-
-	`git checkout master`
-
-*2.* 配置 Misskey
-----------------------------------------------------------------
-
-可以按照如下方式创建配置文件:
-
-``` bash
-cd .config
-cp example.yml default.yml
-cp docker_example.env docker.env
-```
-
-### `default.yml`
-
-这个文件的编辑工作基本与非 Docker 环境的版本相同。
-但请注意, Postgresql、 Redis 和 Elasticsearch 的 **主机名(hostname)** 配置不应该是 `localhost` ,它们被设置在 `docker-compose.yml` 文件中。
-以下是默认的主机名:
-
-| 服务          | 主机名   |
-|---------------|----------|
-| Postgresql    | `db`     |
-| Redis         | `redis`  |
-| Elasticsearch | `es`     |
-
-### `docker.env`
-
-在这个文件中配置 Postgresql 。
-至少需要如下这些配置:
-
-| 名称                |  描述         |
-|---------------------|---------------|
-| `POSTGRES_PASSWORD` |  数据库密码   |
-| `POSTGRES_USER`     |  数据库用户名 |
-| `POSTGRES_DB`       |  数据库名     |
-
-*3.* 配置 Docker
-----------------------------------------------------------------
-编辑 `docker-compose.yml` 文件。
-
-*4.* 构建 Misskey
-----------------------------------------------------------------
-使用如下的方式构建Misskey:
-
-`docker-compose build`
-
-*5.* 初始化数据库
-----------------------------------------------------------------
-``` bash
-docker-compose run --rm web yarn run init
-```
-
-*6.* 完成了!
-----------------------------------------------------------------
-干得不错!现在您拥有了一个可以运行Misskey的环境啦。
-
-### 正常启动
-只需要 `docker-compose up -d` 即可。玩得愉快!
-
-### 如何将您的 Misskey 服务器升级至最新版本
-1. `git stash`
-2. `git checkout master`
-3. `git pull`
-4. `git submodule update --init`
-5. `git stash pop`
-6. `docker-compose build`
-7. 检查 [更新日志](../CHANGELOG.md) 以获取升级迁移信息。
-8. `docker-compose stop && docker-compose up -d`
-
-### 如何执行 [控制台指令](manage.zh.md):
-`docker-compose run --rm web node built/tools/mark-admin @example`
-
-----------------------------------------------------------------
-
-如果您有任何疑问或是困惑,欢迎与我们联系!
diff --git a/docs/examples/misskey.nginx b/docs/examples/misskey.nginx
deleted file mode 100644
index b558fb023a..0000000000
--- a/docs/examples/misskey.nginx
+++ /dev/null
@@ -1,71 +0,0 @@
-# Sample nginx configuration for Misskey
-#
-# 1. Replace example.tld to your domain
-# 2. Copy to /etc/nginx/sites-available/ and then symlink from /etc/nginx/sites-enabled/
-#    or copy to /etc/nginx/conf.d/
-
-# For WebSocket
-map $http_upgrade $connection_upgrade {
-    default upgrade;
-    ''      close;
-}
-
-proxy_cache_path /tmp/nginx_cache levels=1:2 keys_zone=cache1:16m max_size=1g inactive=720m use_temp_path=off;
-
-server {
-    listen 80;
-    listen [::]:80;
-    server_name example.tld;
-
-    # For SSL domain validation
-    root /var/www/html;
-    location /.well-known/acme-challenge/ { allow all; }
-    location /.well-known/pki-validation/ { allow all; }
-    location / { return 301 https://$server_name$request_uri; }
-}
-
-server {
-    listen 443 ssl http2;
-    listen [::]:443 ssl http2;
-    server_name example.tld;
-    ssl_session_cache shared:ssl_session_cache:10m;
-
-    # To use Let's Encrypt certificate
-    ssl_certificate     /etc/letsencrypt/live/example.tld/fullchain.pem;
-    ssl_certificate_key /etc/letsencrypt/live/example.tld/privkey.pem;
-
-    # To use Debian/Ubuntu's self-signed certificate (For testing or before issuing a certificate)
-    #ssl_certificate     /etc/ssl/certs/ssl-cert-snakeoil.pem;
-    #ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key;
-
-    # SSL protocol settings
-    ssl_protocols TLSv1.2;
-    ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA:ECDHE-RSA-AES128-SHA:AES128-SHA;
-    ssl_prefer_server_ciphers on;
-
-    # Change to your upload limit
-    client_max_body_size 80m;
-
-    # Proxy to Node
-    location / {
-        proxy_pass http://127.0.0.1:3000;
-        proxy_set_header Host $host;
-        proxy_http_version 1.1;
-        proxy_redirect off;
-
-        # If it's behind another reverse proxy or CDN, remove the following.
-        proxy_set_header X-Real-IP $remote_addr;
-        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
-        proxy_set_header X-Forwarded-Proto https;
-
-        # For WebSocket
-        proxy_set_header Upgrade $http_upgrade;
-        proxy_set_header Connection $connection_upgrade;
-
-        # Cache settings
-        proxy_cache cache1;
-        proxy_cache_lock on;
-        proxy_cache_use_stale updating;
-        add_header X-Cache $upstream_cache_status;
-    }
-}
diff --git a/docs/manage.en.md b/docs/manage.en.md
deleted file mode 100644
index d310e9531f..0000000000
--- a/docs/manage.en.md
+++ /dev/null
@@ -1,14 +0,0 @@
-# Management guide
-
-## Check the status of the job queue
-coming soon
-
-## Mark as 'admin' user
-``` shell
-node built/tools/mark-admin (Username)
-```
-
-e.g.
-``` shell
-node built/tools/mark-admin @syuilo
-```
diff --git a/docs/manage.fr.md b/docs/manage.fr.md
deleted file mode 100644
index 0b2b7ffc16..0000000000
--- a/docs/manage.fr.md
+++ /dev/null
@@ -1,14 +0,0 @@
-# Guide d'administration
-
-## Vérifier le status de la file d'attente des taches
-coming soon
-
-## Marquer un utilisateur en tant que 'admin'
-``` shell
-node built/tools/mark-admin (nom d'utilisateur)
-```
-
-Exemple :
-``` shell
-node built/tools/mark-admin @syuilo
-```
diff --git a/docs/manage.ja.md b/docs/manage.ja.md
deleted file mode 100644
index 55596add10..0000000000
--- a/docs/manage.ja.md
+++ /dev/null
@@ -1,14 +0,0 @@
-# 運営ガイド
-
-## ジョブキューの状態を調べる
-coming soon
-
-## 管理者ユーザーを設定する
-``` shell
-node built/tools/mark-admin (ユーザー名)
-```
-
-例:
-``` shell
-node built/tools/mark-admin @syuilo
-```
diff --git a/docs/manage.zh.md b/docs/manage.zh.md
deleted file mode 100644
index 520d150203..0000000000
--- a/docs/manage.zh.md
+++ /dev/null
@@ -1,14 +0,0 @@
-# 运营指南
-
-## 检查任务队列的状态
-即将到来……
-
-## 设置用户为管理员
-``` shell
-node built/tools/mark-admin (用户名)
-```
-
-样例
-``` shell
-node built/tools/mark-admin @syuilo
-```
diff --git a/docs/push-docker-hub.ja.md b/docs/push-docker-hub.ja.md
deleted file mode 100644
index 923e4c16e0..0000000000
--- a/docs/push-docker-hub.ja.md
+++ /dev/null
@@ -1,28 +0,0 @@
-GitHub Actionsを使用してDocker Hubへpushする方法
-================================================================
-
-[/.github/workflows/docker.yml](/.github/workflows/docker.yml) に  
-GitHub ActionによりDocker Hubへpushするワークフローが記述されています。
-
-オリジナルリポジトリでは、リリースされたタイミングで `latest`, `<リリース名>` それぞれのタグでDocker Hubにpushされます。  
-※ Docker Hub に`<ブランチ名>`のようなタグがあるかもしれませんが、こちらは自動push対象ではありません。
-
-Fork先でこのワークフローを実行すると失敗します。
-
-以下では、Fork先で自分のDocker Hubリポジトリにpushするようにする方法を記述します。
-
-## 自分のDocker Hubリポジトリにpushするように設定する方法
-
-1. Docker Hubでリポジトリを作成します。
-2. ワークフローファイルの [images](https://github.com/misskey-dev/misskey/blob/53f3b779bf16abcda4f6e026c51384f3b8fbcc62/.github/workflows/docker.yml#L20) を作成したリポジトリに置き換えます。
-3. GitHubにて [暗号化されたシークレット](https://docs.github.com/ja/actions/reference/encrypted-secrets#creating-encrypted-secrets-for-a-repository) を作成します。  
-   作成が必要なのは `DOCKER_USERNAME` と `DOCKER_PASSWORD` で、それぞれDocker Hubのユーザーとパスワードになります。
-
-## pushする方法
-
-上記設定によりリリース時に自動的にDocker Hubにpushされるようになります。  
-具体的には、GitHubのリリース機能でリリースしたタイミングで `latest`, `<リリース名>` それぞれのタグでDocker Hubにpushされます。
-
-また、GitHub上から手動でpushすることも出来ます。  
-それを行うには、Actions => Publish Docker image => Run workflow からbranchを選択してワークフローを実行します。  
-ただし、この場合作成されるタグは`<ブランチ名>`になります。
diff --git a/docs/setup.en.md b/docs/setup.en.md
deleted file mode 100644
index dfe50a6600..0000000000
--- a/docs/setup.en.md
+++ /dev/null
@@ -1,147 +0,0 @@
-Misskey Setup and Installation Guide
-================================================================
-
-We thank you for your interest in setting up your Misskey server!
-This guide describes how to install and setup Misskey.
-
-- [Japanese version also available - 日本語版もあります](./setup.ja.md)
-- [Simplified Chinese version also available - 简体中文版同样可用](./setup.zh.md)
-
-----------------------------------------------------------------
-
-*1.* Create Misskey user
-----------------------------------------------------------------
-Running misskey as root is not a good idea so we create a user for that.
-In debian for exemple :
-
-```
-adduser --disabled-password --disabled-login misskey
-```
-
-*2.* Install dependencies
-----------------------------------------------------------------
-Please install and setup these softwares:
-
-#### Dependencies :package:
-* **[Node.js](https://nodejs.org/en/)** (12.x, 14.x)
-* **[PostgreSQL](https://www.postgresql.org/)** (12.x / 13.x is preferred)
-* **[Redis](https://redis.io/)**
-
-##### Optional
-* [Yarn](https://yarnpkg.com/) *Optional but recommended for security reason. If you won't install it, use `npx yarn` instead of `yarn`.*
-* [Elasticsearch](https://www.elastic.co/) - required to enable the search feature
-* [FFmpeg](https://www.ffmpeg.org/)
-
-*3.* Install Misskey
-----------------------------------------------------------------
-1. Connect to misskey user.
-
-	`su - misskey`
-
-2. Clone the misskey repo from master branch.
-
-	`git clone -b master git://github.com/misskey-dev/misskey.git`
-
-3. Navigate to misskey directory
-
-	`cd misskey`
-
-4. Checkout to the [latest release](https://github.com/misskey-dev/misskey/releases/latest)
-
-	`git checkout master`
-
-5. Install misskey dependencies.
-
-	`yarn`
-
-*4.* Configure Misskey
-----------------------------------------------------------------
-1. Copy the `.config/example.yml` and rename it to `default.yml`.
-
-	`cp .config/example.yml .config/default.yml`
-
-2. Edit `default.yml`
-
-*5.* Build Misskey
-----------------------------------------------------------------
-
-Build misskey with the following:
-
-`NODE_ENV=production yarn build`
-
-If you're on Debian, you will need to install the `build-essential`, `python` package.
-
-If you're still encountering errors about some modules, use node-gyp:
-
-1. `npx node-gyp configure`
-2. `npx node-gyp build`
-3. `NODE_ENV=production yarn build`
-
-*6.* Init DB
-----------------------------------------------------------------
-``` shell
-yarn run init
-```
-
-*7.* That is it.
-----------------------------------------------------------------
-Well done! Now, you have an environment that run to Misskey.
-
-### Launch normally
-Just `NODE_ENV=production npm start`. GLHF!
-
-### Launch with systemd
-
-1. Create a systemd service here
-
-	`/etc/systemd/system/misskey.service`
-
-2. Edit it, and paste this and save:
-
-	```
-	[Unit]
-	Description=Misskey daemon
-
-	[Service]
-	Type=simple
-	User=misskey
-	ExecStart=/usr/bin/npm start
-	WorkingDirectory=/home/misskey/misskey
-	Environment="NODE_ENV=production"
-	TimeoutSec=60
-	StandardOutput=syslog
-	StandardError=syslog
-	SyslogIdentifier=misskey
-	Restart=always
-
-	[Install]
-	WantedBy=multi-user.target
-	```
-
-3. Reload systemd and enable the misskey service.
-
-	`systemctl daemon-reload ; systemctl enable misskey`
-
-4. Start the misskey service.
-
-	`systemctl start misskey`
-
-You can check if the service is running with `systemctl status misskey`.
-
-### How to update your Misskey server to the latest version
-1. `git checkout master`
-2. `git pull`
-3. `git submodule update --init`
-4. `yarn install`
-5. `NODE_ENV=production yarn build`
-6. `yarn migrate`
-7. Restart your Misskey process to apply changes
-8. Enjoy
-
-If you encounter any problems with updating, please try the following:
-1. `yarn clean` or `yarn cleanall`
-2. Retry update (Don't forget `yarn install`
-
-----------------------------------------------------------------
-
-If you have any questions or troubles, feel free to contact us!
diff --git a/docs/setup.fr.md b/docs/setup.fr.md
deleted file mode 100644
index f38c7a8eab..0000000000
--- a/docs/setup.fr.md
+++ /dev/null
@@ -1,136 +0,0 @@
-Guide d'installation et de configuration de Misskey
-================================================================
-
-Nous vous remerçions de l'intrêt que vous manifestez pour l'installation de votre propre instance Misskey !
-Ce guide décrit les étapes à suivre afin d'installer et de configurer une instance Misskey.
-
-- [La version en japonnais est également disponible sur - 日本語版もあります](./setup.ja.md)
-- [Version anglaise également disponible - English version also available - 英語版もあります](./setup.en.md)
-- [Version Chinois simplifié également disponible - Simplified Chinese version also available - 简体中文版同样可用](./setup.zh.md)
-
-----------------------------------------------------------------
-
-*1.* Création de l'utilisateur Misskey
-----------------------------------------------------------------
-Executer misskey en tant que super-utilisateur étant une mauvaise idée, nous allons créer un utilisateur dédié.
-Sous Debian, par exemple :
-
-```
-adduser --disabled-password --disabled-login misskey
-```
-
-*2.* Installation des dépendances
-----------------------------------------------------------------
-Installez les paquets suivants :
-
-#### Dépendences :package:
-* **[Node.js](https://nodejs.org/en/)** (12.x, 14.x)
-* **[PostgreSQL](https://www.postgresql.org/)** (>= 10)
-* **[Redis](https://redis.io/)**
-
-##### Optionnels
-* [Yarn](https://yarnpkg.com/) - *recommander pour des raisons de sécurité. Si vous ne l'installez pas, utilisez `npx yarn` au lieu de` yarn`.*
-* [Elasticsearch](https://www.elastic.co/) - *requis pour pouvoir activer la fonctionnalité de recherche.*
-* [FFmpeg](https://www.ffmpeg.org/)
-
-*3.* Installation de Misskey
-----------------------------------------------------------------
-1. Basculez vers l'utilisateur misskey.
-
-	`su - misskey`
-
-2. Clonez la branche master du dépôt misskey.
-
-	`git clone -b master git://github.com/misskey-dev/misskey.git`
-
-3. Accédez au dossier misskey.
-
-	`cd misskey`
-
-4. Checkout sur le tag de la [version la plus récente](https://github.com/misskey-dev/misskey/releases/latest)
-
-	`git checkout master`
- 
-5. Installez les dépendances de misskey.
-
-	`yarn install`
-
-*4.* Création du fichier de configuration
-----------------------------------------------------------------
-1. Copiez le fichier `.config/example.yml` et renommez-le`default.yml`.
-
-	`cp .config/example.yml .config/default.yml`
-
-2. Editez le fichier `default.yml`
-
-*5.* Construction de Misskey
-----------------------------------------------------------------
-
-Construisez Misskey comme ceci :
-
-`NODE_ENV=production yarn build`
-
-Si vous êtes sous Debian, vous serez amené à installer les paquets `build-essential` et `python`.
-
-Si vous rencontrez des erreurs concernant certains modules, utilisez node-gyp:
-
-1. `npx node-gyp configure`
-2. `npx node-gyp build`
-3. `NODE_ENV=production yarn build`
-
-*6.* C'est tout.
-----------------------------------------------------------------
-Excellent ! Maintenant, vous avez un environnement prêt pour lancer Misskey
-
-### Lancement conventionnel
-Lancez tout simplement `NODE_ENV=production yarn start`. Bonne chance et amusez-vous bien !
-
-### Démarrage avec systemd
-
-1. Créez un service systemd sur
-
-	`/etc/systemd/system/misskey.service`
-
-2. Editez-le puis copiez et coller ceci dans le fichier :
-
-	```
-	[Unit]
-	Description=Misskey daemon
-
-	[Service]
-	Type=simple
-	User=misskey
-	ExecStart=/usr/bin/npm start
-	WorkingDirectory=/home/misskey/misskey
-	Environment="NODE_ENV=production"
-	TimeoutSec=60
-	StandardOutput=syslog
-	StandardError=syslog
-	SyslogIdentifier=misskey
-	Restart=always
-
-	[Install]
-	WantedBy=multi-user.target
-	```
-
-3. Redémarre systemd et active le service misskey.
-
-	`systemctl daemon-reload ; systemctl enable misskey`
-
-4. Démarre le service misskey.
-
-	`systemctl start misskey`
-
-Vous pouvez vérifier si le service a démarré en utilisant la commande `systemctl status misskey`.
-
-### Méthode de mise à jour vers la plus récente version de Misskey
-1. `git checkout master`
-2. `git pull`
-3. `git submodule update --init`
-4. `yarn install`
-5. `NODE_ENV=production yarn build`
-6. `yarn migrate`
-
-----------------------------------------------------------------
-
-Si vous rencontrez des difficultés ou avez d'autres questions, n'hésitez pas à nous contacter !
diff --git a/docs/setup.ja.md b/docs/setup.ja.md
deleted file mode 100644
index 5681ee8c51..0000000000
--- a/docs/setup.ja.md
+++ /dev/null
@@ -1,145 +0,0 @@
-Misskey構築の手引き
-================================================================
-
-Misskeyサーバーの構築にご関心をお寄せいただきありがとうございます!
-このガイドではMisskeyのインストール・セットアップ方法について解説します。
-
-- [英語版もあります - English version also available](./setup.en.md)
-- [简体中文版同样可用 - Simplified Chinese version also available](./setup.zh.md)
-
-----------------------------------------------------------------
-
-*1.* Misskeyユーザーの作成
-----------------------------------------------------------------
-Misskeyはrootユーザーで実行しない方がよいため、代わりにユーザーを作成します。
-Debianの例:
-
-```
-adduser --disabled-password --disabled-login misskey
-```
-
-*2.* 依存関係をインストールする
-----------------------------------------------------------------
-これらのソフトウェアをインストール・設定してください:
-
-#### 依存関係 :package:
-* **[Node.js](https://nodejs.org/en/)** (12.x, 14.x)
-* **[PostgreSQL](https://www.postgresql.org/)** (10以上)
-* **[Redis](https://redis.io/)**
-
-##### オプション
-* [Yarn](https://yarnpkg.com/)
-	* セキュリティの観点から推奨されます。 yarn をインストールしない方針の場合は、文章中の `yarn` を適宜 `npx yarn` と読み替えてください。
-* [Elasticsearch](https://www.elastic.co/)
-	* 検索機能を有効にするためにはインストールが必要です。
-* [FFmpeg](https://www.ffmpeg.org/)
-
-*3.* Misskeyのインストール
-----------------------------------------------------------------
-1. misskeyユーザーを使用
-
-	`su - misskey`
-
-2. masterブランチからMisskeyレポジトリをクローン
-
-	`git clone -b master git://github.com/misskey-dev/misskey.git`
-
-3. misskeyディレクトリに移動
-
-	`cd misskey`
-
-4. [最新のリリース](https://github.com/misskey-dev/misskey/releases/latest)を確認
-
-	`git checkout master`
-
-5. Misskeyの依存パッケージをインストール
-
-	`yarn install`
-
-*4.* 設定ファイルを作成する
-----------------------------------------------------------------
-1. `.config/example.yml`をコピーし名前を`default.yml`にする。
-
-	`cp .config/example.yml .config/default.yml`
-
-2. `default.yml` を編集する。
-
-*5.* Misskeyのビルド
-----------------------------------------------------------------
-
-次のコマンドでMisskeyをビルドしてください:
-
-`NODE_ENV=production yarn build`
-
-Debianをお使いであれば、`build-essential`パッケージをインストールする必要があります。
-
-何らかのモジュールでエラーが発生する場合はnode-gypを使ってください:
-1. `npx node-gyp configure`
-2. `npx node-gyp build`
-3. `NODE_ENV=production yarn build`
-
-*6.* データベースを初期化
-----------------------------------------------------------------
-``` shell
-yarn run init
-```
-
-*7.* 以上です!
-----------------------------------------------------------------
-お疲れ様でした。これでMisskeyを動かす準備は整いました。
-
-### 通常起動
-`NODE_ENV=production yarn start`するだけです。GLHF!
-
-### systemdを用いた起動
-1. systemdサービスのファイルを作成
-
-	`/etc/systemd/system/misskey.service`
-
-2. エディタで開き、以下のコードを貼り付けて保存:
-
-	```
-	[Unit]
-	Description=Misskey daemon
-
-	[Service]
-	Type=simple
-	User=misskey
-	ExecStart=/usr/bin/npm start
-	WorkingDirectory=/home/misskey/misskey
-	Environment="NODE_ENV=production"
-	TimeoutSec=60
-	StandardOutput=syslog
-	StandardError=syslog
-	SyslogIdentifier=misskey
-	Restart=always
-
-	[Install]
-	WantedBy=multi-user.target
-	```
-
-	CentOSで1024以下のポートを使用してMisskeyを使用する場合は`ExecStart=/usr/bin/sudo /usr/bin/npm start`に変更する必要があります。
-
-3. systemdを再読み込みしmisskeyサービスを有効化
-
-	`systemctl daemon-reload; systemctl enable misskey`
-
-4. misskeyサービスの起動
-
-	`systemctl start misskey`
-
-`systemctl status misskey`と入力すると、サービスの状態を調べることができます。
-
-### Misskeyを最新バージョンにアップデートする方法:
-1. `git checkout master`
-2. `git pull`
-3. `git submodule update --init`
-4. `yarn install`
-5. `NODE_ENV=production yarn build`
-6. `yarn migrate`
-
-なにか問題が発生した場合は、`yarn clean`または`yarn cleanall`すると直る場合があります。
-
-----------------------------------------------------------------
-
-なにかお困りのことがありましたらお気軽にご連絡ください。
diff --git a/docs/setup.zh.md b/docs/setup.zh.md
deleted file mode 100644
index 26a72f0d05..0000000000
--- a/docs/setup.zh.md
+++ /dev/null
@@ -1,147 +0,0 @@
-Misskey 设置和安装指南
-================================================================
-
-非常感谢您对构建 Misskey 服务器的关注!
-这份指南描述了 Misskey 的安装与设置流程。
-
-- [日本語版もあります - Japanese version also available](./setup.ja.md)
-- [英語版もあります - English version also available](./setup.en.md)
-
-----------------------------------------------------------------
-
-*1.* 创建 Misskey 用户
-----------------------------------------------------------------
-直接使用 root 用户来运行 misskey 也许并不是一个好主意,因此我们有必要创建一个专用的用户。
-以 Debian 为例:
-
-``` bash
-adduser --disabled-password --disabled-login misskey
-```
-
-*2.* 安装依赖
-----------------------------------------------------------------
-请安装并设置如下这些软件:
-
-#### Dependencies :package:
-* **[Node.js](https://nodejs.org/en/)** (12.x, 14.x)
-* **[PostgreSQL](https://www.postgresql.org/)** (>= 10)
-* **[Redis](https://redis.io/)**
-
-##### Optional
-* [Yarn](https://yarnpkg.com/) *可选,但出于安全因素考虑还是推荐安装。如果您没有安装, 您需要使用 `npx yarn` 来代替 `yarn`.*
-* [Elasticsearch](https://www.elastic.co/) - 为了启用搜索功能,这个搜索引擎是有必要的。
-* [FFmpeg](https://www.ffmpeg.org/)
-
-*3.* 安装 Misskey
-----------------------------------------------------------------
-1. 连接至 misskey 用户.
-
-	`su - misskey`
-
-2. 克隆 Misskey 项目的 master 分支。
-
-	`git clone -b master git://github.com/misskey-dev/misskey.git`
-
-3. 进入 misskey 文件夹。
-
-	`cd misskey`
-
-4. 检查 [最新发布版](https://github.com/misskey-dev/misskey/releases/latest) 标签。
-
-	`git checkout master`
-
-5. 安装 Misskey 的依赖。
-
-	`yarn`
-
-*4.* 配置 Misskey
-----------------------------------------------------------------
-1. 复制 `.config/example.yml` 并重命名为 `default.yml`。
-
-	`cp .config/example.yml .config/default.yml`
-
-2. 编辑 `default.yml`
-
-*5.* 构建 Misskey
-----------------------------------------------------------------
-
-使用如下的指令构建 Misskey :
-
-`NODE_ENV=production yarn build`
-
-如果您使用的是 Debian , 您需要安装 `build-essential`, `python` 环境包。
-
-如果您仍然遇到有关某些模块的错误,您可以使用 node-gyp:
-
-1. `npx node-gyp configure`
-2. `npx node-gyp build`
-3. `NODE_ENV=production yarn build`
-
-*6.* 初始化数据库
-----------------------------------------------------------------
-``` bash
-yarn run init
-```
-
-*7.* 完成了!
-----------------------------------------------------------------
-干得不错!现在您拥有了一个可以运行Misskey的环境啦。
-
-### 正常启动
-只需要 `NODE_ENV=production npm start` 即可。玩得愉快!
-
-### 使用 systemd 来启动
-
-1. 在此处创建一个 systemd 服务:
-
-	`/etc/systemd/system/misskey.service`
-
-2. 编辑它,粘贴如下内容并保存:
-
-	```
-	[Unit]
-	Description=Misskey daemon
-
-	[Service]
-	Type=simple
-	User=misskey
-	ExecStart=/usr/bin/npm start
-	WorkingDirectory=/home/misskey/misskey
-	Environment="NODE_ENV=production"
-	TimeoutSec=60
-	StandardOutput=syslog
-	StandardError=syslog
-	SyslogIdentifier=misskey
-	Restart=always
-
-	[Install]
-	WantedBy=multi-user.target
-	```
-
-3. 重启 systemd 并设置 misskey 服务自动启动:
-
-	`systemctl daemon-reload ; systemctl enable misskey`
-
-4. 启动 misskey 服务:
-
-	`systemctl start misskey`
-
-您可以使用 `systemctl status misskey` 来检查服务是否正在运行。
-
-### 如何将您的 Misskey 服务器升级至最新版本
-1. `git checkout master`
-2. `git pull`
-3. `git submodule update --init`
-4. `yarn install`
-5. `NODE_ENV=production yarn build`
-6. `yarn migrate`
-7. 重启您的 Misskey 进程来应用改变。
-8. 尽情享受吧!
-
-如果您在更新时遇到任何问题,请尝试以下操作:
-1. `yarn clean` 或是 `yarn cleanall`
-2. 重试升级 (请不要忘记 `yarn install` )
-
-----------------------------------------------------------------
-
-如果您有任何疑问或是困惑,欢迎与我们联系!
diff --git a/package.json b/package.json
index 10ddea2c1e..f60a51b940 100644
--- a/package.json
+++ b/package.json
@@ -60,6 +60,9 @@
 		"@types/jsonld": "1.5.6",
 		"@types/katex": "0.11.1",
 		"@types/koa": "2.13.4",
+		"@types/koa__cors": "3.0.3",
+		"@types/koa__multer": "2.0.3",
+		"@types/koa__router": "8.0.8",
 		"@types/koa-bodyparser": "4.3.3",
 		"@types/koa-cors": "0.0.2",
 		"@types/koa-favicon": "2.0.21",
@@ -67,9 +70,6 @@
 		"@types/koa-mount": "4.0.1",
 		"@types/koa-send": "4.1.3",
 		"@types/koa-views": "7.0.0",
-		"@types/koa__cors": "3.0.3",
-		"@types/koa__multer": "2.0.3",
-		"@types/koa__router": "8.0.8",
 		"@types/markdown-it": "12.2.3",
 		"@types/matter-js": "0.17.5",
 		"@types/mocha": "8.2.3",
@@ -119,7 +119,9 @@
 		"cafy": "15.2.1",
 		"cbor": "8.0.2",
 		"chalk": "4.1.2",
-		"chart.js": "2.9.4",
+		"chart.js": "3.5.1",
+		"chartjs-adapter-date-fns": "2.0.0",
+		"chartjs-plugin-zoom": "1.1.1",
 		"cli-highlight": "2.1.11",
 		"compare-versions": "3.6.0",
 		"concurrently": "6.3.0",
@@ -127,6 +129,7 @@
 		"crc-32": "1.2.0",
 		"css-loader": "6.4.0",
 		"cssnano": "5.0.8",
+		"date-fns": "2.25.0",
 		"dateformat": "4.5.1",
 		"escape-regexp": "0.0.1",
 		"eslint": "8.0.1",
diff --git a/src/client/components/chart.vue b/src/client/components/chart.vue
new file mode 100644
index 0000000000..3599266cb6
--- /dev/null
+++ b/src/client/components/chart.vue
@@ -0,0 +1,628 @@
+<template>
+<canvas ref="chartEl"></canvas>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, ref, watch, PropType } from 'vue';
+import {
+	Chart,
+	ArcElement,
+	LineElement,
+	BarElement,
+	PointElement,
+	BarController,
+	LineController,
+	CategoryScale,
+	LinearScale,
+	TimeScale,
+	Legend,
+	Title,
+	Tooltip,
+	SubTitle,
+	Filler,
+} from 'chart.js';
+import 'chartjs-adapter-date-fns';
+import { enUS } from 'date-fns/locale';
+import zoomPlugin from 'chartjs-plugin-zoom';
+import * as os from '@client/os';
+import { defaultStore } from '@client/store';
+
+Chart.register(
+	ArcElement,
+	LineElement,
+	BarElement,
+	PointElement,
+	BarController,
+	LineController,
+	CategoryScale,
+	LinearScale,
+	TimeScale,
+	Legend,
+	Title,
+	Tooltip,
+	SubTitle,
+	Filler,
+	zoomPlugin,
+);
+
+const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b));
+const negate = arr => arr.map(x => -x);
+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})`;
+};
+
+const colors = ['#008FFB', '#00E396', '#FEB019', '#FF4560'];
+const getColor = (i) => {
+	return colors[i % colors.length];
+};
+
+export default defineComponent({
+	props: {
+		src: {
+			type: String,
+			required: true,
+		},
+		args: {
+			type: Object,
+			required: false,
+		},
+		limit: {
+			type: Number,
+			required: false,
+			default: 90
+		},
+		span: {
+			type: String as PropType<'hour' | 'day'>,
+			required: true,
+		},
+		detailed: {
+			type: Boolean,
+			required: false,
+			default: false
+		},
+	},
+
+	setup(props) {
+		const now = new Date();
+		let chartInstance: Chart = null;
+		let data: {
+			series: {
+				name: string;
+				type: 'line' | 'area';
+				color?: string;
+				borderDash?: number[];
+				hidden?: boolean;
+				data: {
+					x: number;
+					y: number;
+				}[];
+			}[];
+		} = null;
+
+		const chartEl = ref<HTMLCanvasElement>(null);
+		const fetching = ref(true);
+
+		const getDate = (ago: number) => {
+			const y = now.getFullYear();
+			const m = now.getMonth();
+			const d = now.getDate();
+			const h = now.getHours();
+
+			return props.span === 'day' ? new Date(y, m, d - ago) : new Date(y, m, d, h - ago);
+		};
+
+		const format = (arr) => {
+			return arr.map((v, i) => ({
+				x: getDate(i).getTime(),
+				y: v
+			}));
+		};
+
+		const render = () => {
+			if (chartInstance) {
+				chartInstance.destroy();
+			}
+
+			const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
+
+			// フォントカラー
+			Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+
+			chartInstance = new Chart(chartEl.value, {
+				type: 'line',
+				data: {
+					labels: new Array(props.limit).fill(0).map((_, i) => getDate(i).toLocaleString()).slice().reverse(),
+					datasets: data.series.map((x, i) => ({
+						parsing: false,
+						label: x.name,
+						data: x.data.slice().reverse(),
+						pointRadius: 0,
+						tension: 0,
+						borderWidth: 2,
+						borderColor: x.color ? x.color : getColor(i),
+						borderDash: x.borderDash || [],
+						borderJoinStyle: 'round',
+						backgroundColor: alpha(x.color ? x.color : getColor(i), 0.1),
+						fill: x.type === 'area',
+						hidden: !!x.hidden,
+					})),
+				},
+				options: {
+					aspectRatio: 2.5,
+					layout: {
+						padding: {
+							left: 16,
+							right: 16,
+							top: 16,
+							bottom: 8,
+						},
+					},
+					scales: {
+						x: {
+							type: 'time',
+							time: {
+								stepSize: 1,
+								unit: props.span === 'day' ? 'month' : 'day',
+							},
+							grid: {
+								display: props.detailed,
+								color: gridColor,
+								borderColor: 'rgb(0, 0, 0, 0)',
+							},
+							ticks: {
+								display: props.detailed,
+							},
+							adapters: {
+								date: {
+									locale: enUS,
+								},
+							},
+							min: getDate(props.limit).getTime(),
+						},
+						y: {
+							position: 'left',
+							grid: {
+								color: gridColor,
+								borderColor: 'rgb(0, 0, 0, 0)',
+							},
+							ticks: {
+								display: props.detailed,
+							},
+						},
+					},
+					interaction: {
+						intersect: false,
+					},
+					plugins: {
+						legend: {
+							position: 'bottom',
+							labels: {
+								boxWidth: 16,
+							},
+						},
+						tooltip: {
+							mode: 'index',
+							animation: {
+								duration: 0,
+							},
+						},
+						zoom: {
+							pan: {
+								enabled: true,
+							},
+							zoom: {
+								wheel: {
+									enabled: true,
+								},
+								pinch: {
+									enabled: true,
+								},
+								drag: {
+									enabled: false,
+								},
+								mode: 'x',
+							},
+							limits: {
+								x: {
+									min: 'original',
+									max: 'original',
+								},
+								y: {
+									min: 'original',
+									max: 'original',
+								},
+							}
+						},
+					},
+				},
+			});
+		};
+
+		const exportData = () => {
+			// TODO
+		};
+
+		const fetchFederationInstancesChart = async (total: boolean): Promise<typeof data> => {
+			const raw = await os.api('charts/federation', { limit: props.limit, span: props.span });
+			return {
+				series: [{
+					name: 'Instances',
+					type: 'area',
+					data: format(total
+						? raw.instance.total
+						: sum(raw.instance.inc, negate(raw.instance.dec))
+					),
+				}],
+			};
+		};
+
+		const fetchNotesChart = async (type: string): Promise<typeof data> => {
+			const raw = await os.api('charts/notes', { limit: props.limit, span: props.span });
+			return {
+				series: [{
+					name: 'All',
+					type: 'line',
+					borderDash: [5, 5],
+					data: format(type == 'combined'
+						? sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec))
+						: sum(raw[type].inc, negate(raw[type].dec))
+					),
+				}, {
+					name: 'Renotes',
+					type: 'area',
+					data: format(type == 'combined'
+						? sum(raw.local.diffs.renote, raw.remote.diffs.renote)
+						: raw[type].diffs.renote
+					),
+				}, {
+					name: 'Replies',
+					type: 'area',
+					data: format(type == 'combined'
+						? sum(raw.local.diffs.reply, raw.remote.diffs.reply)
+						: raw[type].diffs.reply
+					),
+				}, {
+					name: 'Normal',
+					type: 'area',
+					data: format(type == 'combined'
+						? sum(raw.local.diffs.normal, raw.remote.diffs.normal)
+						: raw[type].diffs.normal
+					),
+				}],
+			};
+		};
+
+		const fetchNotesTotalChart = async (): Promise<typeof data> => {
+			const raw = await os.api('charts/notes', { limit: props.limit, span: props.span });
+			return {
+				series: [{
+					name: 'Combined',
+					type: 'line',
+					data: format(sum(raw.local.total, raw.remote.total)),
+				}, {
+					name: 'Local',
+					type: 'area',
+					data: format(raw.local.total),
+				}, {
+					name: 'Remote',
+					type: 'area',
+					data: format(raw.remote.total),
+				}],
+			};
+		};
+
+		const fetchUsersChart = async (total: boolean): Promise<typeof data> => {
+			const raw = await os.api('charts/users', { limit: props.limit, span: props.span });
+			return {
+				series: [{
+					name: 'Combined',
+					type: 'line',
+					data: format(total
+						? sum(raw.local.total, raw.remote.total)
+						: sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec))
+					),
+				}, {
+					name: 'Local',
+					type: 'area',
+					data: format(total
+						? raw.local.total
+						: sum(raw.local.inc, negate(raw.local.dec))
+					),
+				}, {
+					name: 'Remote',
+					type: 'area',
+					data: format(total
+						? raw.remote.total
+						: sum(raw.remote.inc, negate(raw.remote.dec))
+					),
+				}],
+			};
+		};
+
+		const fetchActiveUsersChart = async (): Promise<typeof data> => {
+			const raw = await os.api('charts/active-users', { limit: props.limit, span: props.span });
+			return {
+				series: [{
+					name: 'Combined',
+					type: 'line',
+					data: format(sum(raw.local.users, raw.remote.users)),
+				}, {
+					name: 'Local',
+					type: 'area',
+					data: format(raw.local.users),
+				}, {
+					name: 'Remote',
+					type: 'area',
+					data: format(raw.remote.users),
+				}],
+			};
+		};
+
+		const fetchDriveChart = async (): Promise<typeof data> => {
+			const raw = await os.api('charts/drive', { limit: props.limit, span: props.span });
+			return {
+				bytes: true,
+				series: [{
+					name: 'All',
+					type: 'line',
+					borderDash: [5, 5],
+					data: format(
+						sum(
+							raw.local.incSize,
+							negate(raw.local.decSize),
+							raw.remote.incSize,
+							negate(raw.remote.decSize)
+						)
+					),
+				}, {
+					name: 'Local +',
+					type: 'area',
+					data: format(raw.local.incSize),
+				}, {
+					name: 'Local -',
+					type: 'area',
+					data: format(negate(raw.local.decSize)),
+				}, {
+					name: 'Remote +',
+					type: 'area',
+					data: format(raw.remote.incSize),
+				}, {
+					name: 'Remote -',
+					type: 'area',
+					data: format(negate(raw.remote.decSize)),
+				}],
+			};
+		};
+
+		const fetchDriveTotalChart = async (): Promise<typeof data> => {
+			const raw = await os.api('charts/drive', { limit: props.limit, span: props.span });
+			return {
+				bytes: true,
+				series: [{
+					name: 'Combined',
+					type: 'line',
+					data: format(sum(raw.local.totalSize, raw.remote.totalSize)),
+				}, {
+					name: 'Local',
+					type: 'area',
+					data: format(raw.local.totalSize),
+				}, {
+					name: 'Remote',
+					type: 'area',
+					data: format(raw.remote.totalSize),
+				}],
+			};
+		};
+
+		const fetchDriveFilesChart = async (): Promise<typeof data> => {
+			const raw = await os.api('charts/drive', { limit: props.limit, span: props.span });
+			return {
+				series: [{
+					name: 'All',
+					type: 'line',
+					borderDash: [5, 5],
+					data: format(
+						sum(
+							raw.local.incCount,
+							negate(raw.local.decCount),
+							raw.remote.incCount,
+							negate(raw.remote.decCount)
+						)
+					),
+				}, {
+					name: 'Local +',
+					type: 'area',
+					data: format(raw.local.incCount),
+				}, {
+					name: 'Local -',
+					type: 'area',
+					data: format(negate(raw.local.decCount)),
+				}, {
+					name: 'Remote +',
+					type: 'area',
+					data: format(raw.remote.incCount),
+				}, {
+					name: 'Remote -',
+					type: 'area',
+					data: format(negate(raw.remote.decCount)),
+				}],
+			};
+		};
+
+		const fetchDriveFilesTotalChart = async (): Promise<typeof data> => {
+			const raw = await os.api('charts/drive', { limit: props.limit, span: props.span });
+			return {
+				series: [{
+					name: 'Combined',
+					type: 'line',
+					data: format(sum(raw.local.totalCount, raw.remote.totalCount)),
+				}, {
+					name: 'Local',
+					type: 'area',
+					data: format(raw.local.totalCount),
+				}, {
+					name: 'Remote',
+					type: 'area',
+					data: format(raw.remote.totalCount),
+				}],
+			};
+		};
+
+		const fetchInstanceRequestsChart = async (): Promise<typeof data> => {
+			const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
+			return {
+				series: [{
+					name: 'In',
+					type: 'area',
+					color: '#008FFB',
+					data: format(raw.requests.received)
+				}, {
+					name: 'Out (succ)',
+					type: 'area',
+					color: '#00E396',
+					data: format(raw.requests.succeeded)
+				}, {
+					name: 'Out (fail)',
+					type: 'area',
+					color: '#FEB019',
+					data: format(raw.requests.failed)
+				}]
+			};
+		};
+
+		const fetchInstanceUsersChart = async (total: boolean): Promise<typeof data> => {
+			const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
+			return {
+				series: [{
+					name: 'Users',
+					type: 'area',
+					color: '#008FFB',
+					data: format(total
+						? raw.users.total
+						: sum(raw.users.inc, negate(raw.users.dec))
+					)
+				}]
+			};
+		};
+
+		const fetchInstanceNotesChart = async (total: boolean): Promise<typeof data> => {
+			const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
+			return {
+				series: [{
+					name: 'Notes',
+					type: 'area',
+					color: '#008FFB',
+					data: format(total
+						? raw.notes.total
+						: sum(raw.notes.inc, negate(raw.notes.dec))
+					)
+				}]
+			};
+		};
+
+		const fetchInstanceFfChart = async (total: boolean): Promise<typeof data> => {
+			const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
+			return {
+				series: [{
+					name: 'Following',
+					type: 'area',
+					color: '#008FFB',
+					data: format(total
+						? raw.following.total
+						: sum(raw.following.inc, negate(raw.following.dec))
+					)
+				}, {
+					name: 'Followers',
+					type: 'area',
+					color: '#00E396',
+					data: format(total
+						? raw.followers.total
+						: sum(raw.followers.inc, negate(raw.followers.dec))
+					)
+				}]
+			};
+		};
+
+		const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof data> => {
+			const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
+			return {
+				bytes: true,
+				series: [{
+					name: 'Drive usage',
+					type: 'area',
+					color: '#008FFB',
+					data: format(total
+						? raw.drive.totalUsage
+						: sum(raw.drive.incUsage, negate(raw.drive.decUsage))
+					)
+				}]
+			};
+		};
+
+		const fetchInstanceDriveFilesChart = async (total: boolean): Promise<typeof data> => {
+			const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
+			return {
+				series: [{
+					name: 'Drive files',
+					type: 'area',
+					color: '#008FFB',
+					data: format(total
+						? raw.drive.totalFiles
+						: sum(raw.drive.incFiles, negate(raw.drive.decFiles))
+					)
+				}]
+			};
+		};
+
+		const fetchAndRender = async () => {
+			const fetchData = () => {
+				switch (props.src) {
+					case 'federation-instances': return fetchFederationInstancesChart(false);
+					case 'federation-instances-total': return fetchFederationInstancesChart(true);
+					case 'users': return fetchUsersChart(false);
+					case 'users-total': return fetchUsersChart(true);
+					case 'active-users': return fetchActiveUsersChart();
+					case 'notes': return fetchNotesChart('combined');
+					case 'local-notes': return fetchNotesChart('local');
+					case 'remote-notes': return fetchNotesChart('remote');
+					case 'notes-total': return fetchNotesTotalChart();
+					case 'drive': return fetchDriveChart();
+					case 'drive-total': return fetchDriveTotalChart();
+					case 'drive-files': return fetchDriveFilesChart();
+					case 'drive-files-total': return fetchDriveFilesTotalChart();
+					
+					case 'instances-requests': return fetchInstanceRequestsChart();
+					case 'instances-users': return fetchInstanceUsersChart(false);
+					case 'instances-users-total': return fetchInstanceUsersChart(true);
+					case 'instances-notes': return fetchInstanceNotesChart(false);
+					case 'instances-notes-total': return fetchInstanceNotesChart(true);
+					case 'instances-ff': return fetchInstanceFfChart(false);
+					case 'instances-ff-total': return fetchInstanceFfChart(true);
+					case 'instances-drive-usage': return fetchInstanceDriveUsageChart(false);
+					case 'instances-drive-usage-total': return fetchInstanceDriveUsageChart(true);
+					case 'instances-drive-files': return fetchInstanceDriveFilesChart(false);
+					case 'instances-drive-files-total': return fetchInstanceDriveFilesChart(true);
+				}
+			};
+			fetching.value = true;
+			data = await fetchData();
+			fetching.value = false;
+			render();
+		};
+
+		watch(() => [props.src, props.span], fetchAndRender);
+
+		onMounted(() => {
+			fetchAndRender();
+		});
+
+		return {
+			chartEl,
+		};
+	},
+});
+</script>
diff --git a/src/client/components/form/input.vue b/src/client/components/form/input.vue
index d7b6f77519..591eda9ed5 100644
--- a/src/client/components/form/input.vue
+++ b/src/client/components/form/input.vue
@@ -33,7 +33,7 @@
 
 <script lang="ts">
 import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue';
-import MkButton from '../ui/button.vue';
+import MkButton from '@client/components/ui/button.vue';
 import { debounce } from 'throttle-debounce';
 
 export default defineComponent({
diff --git a/src/client/components/form/select.vue b/src/client/components/form/select.vue
index 257e2cc990..30ccfd312b 100644
--- a/src/client/components/form/select.vue
+++ b/src/client/components/form/select.vue
@@ -1,9 +1,9 @@
 <template>
 <div class="vblkjoeq">
 	<div class="label" @click="focus"><slot name="label"></slot></div>
-	<div class="input" :class="{ inline, disabled, focused }">
+	<div class="input" :class="{ inline, disabled, focused }" @click.prevent="onClick" ref="container">
 		<div class="prefix" ref="prefixEl"><slot name="prefix"></slot></div>
-		<select ref="inputEl"
+		<select class="select" ref="inputEl"
 			v-model="v"
 			:disabled="disabled"
 			:required="required"
@@ -25,7 +25,8 @@
 
 <script lang="ts">
 import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue';
-import MkButton from '../ui/button.vue';
+import MkButton from '@client/components/ui/button.vue';
+import * as os from '@client/os';
 
 export default defineComponent({
 	components: {
@@ -81,6 +82,7 @@ export default defineComponent({
 		const inputEl = ref(null);
 		const prefixEl = ref(null);
 		const suffixEl = ref(null);
+		const container = ref(null);
 
 		const focus = () => inputEl.value.focus();
 		const onInput = (ev) => {
@@ -132,6 +134,47 @@ export default defineComponent({
 			});
 		});
 
+		const onClick = (ev: MouseEvent) => {
+			focused.value = true;
+
+			const menu = [];
+			let options = context.slots.default();
+
+			for (const optionOrOptgroup of options) {
+				if (optionOrOptgroup.type === 'optgroup') {
+					const optgroup = optionOrOptgroup;
+					menu.push({
+						type: 'label',
+						text: optgroup.props.label,
+					});
+					for (const option of optgroup.children) {
+						menu.push({
+							text: option.children,
+							active: v.value === option.props.value,
+							action: () => {
+								v.value = option.props.value;
+							},
+						});
+					}
+				} else {
+					const option = optionOrOptgroup;
+					menu.push({
+						text: option.children,
+						active: v.value === option.props.value,
+						action: () => {
+							v.value = option.props.value;
+						},
+					});
+				}
+			}
+
+			os.popupMenu(menu, container.value, {
+				width: container.value.offsetWidth,
+			}).then(() => {
+				focused.value = false;
+			});
+		};
+
 		return {
 			v,
 			focused,
@@ -141,8 +184,10 @@ export default defineComponent({
 			inputEl,
 			prefixEl,
 			suffixEl,
+			container,
 			focus,
 			onInput,
+			onClick,
 			updated,
 		};
 	},
@@ -174,8 +219,15 @@ export default defineComponent({
 	> .input {
 		$height: 42px;
 		position: relative;
+		cursor: pointer;
 
-		> select {
+		&:hover {
+			> .select {
+				border-color: var(--inputBorderHover);
+			}
+		}
+
+		> .select {
 			appearance: none;
 			-webkit-appearance: none;
 			display: block;
@@ -195,10 +247,7 @@ export default defineComponent({
 			box-sizing: border-box;
 			cursor: pointer;
 			transition: border-color 0.1s ease-out;
-
-			&:hover {
-				border-color: var(--inputBorderHover);
-			}
+			pointer-events: none;
 		}
 
 		> .prefix,
diff --git a/src/client/components/form/textarea.vue b/src/client/components/form/textarea.vue
index 50be69f930..048e9032df 100644
--- a/src/client/components/form/textarea.vue
+++ b/src/client/components/form/textarea.vue
@@ -26,7 +26,7 @@
 
 <script lang="ts">
 import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue';
-import MkButton from '../ui/button.vue';
+import MkButton from '@client/components/ui/button.vue';
 import { debounce } from 'throttle-debounce';
 
 export default defineComponent({
diff --git a/src/client/components/global/emoji.vue b/src/client/components/global/emoji.vue
index f4ebd5f3b3..f92e35c38f 100644
--- a/src/client/components/global/emoji.vue
+++ b/src/client/components/global/emoji.vue
@@ -27,8 +27,7 @@ export default defineComponent({
 			default: false
 		},
 		customEmojis: {
-			required: false,
-			default: () => []
+			required: false
 		},
 		isReaction: {
 			type: Boolean,
@@ -58,10 +57,7 @@ export default defineComponent({
 		},
 
 		ce() {
-			let ce = [];
-			if (this.customEmojis) ce = ce.concat(this.customEmojis);
-			if (this.$instance && this.$instance.emojis) ce = ce.concat(this.$instance.emojis);
-			return ce;
+			return this.customEmojis || this.$instance?.emojis || [];
 		}
 	},
 
diff --git a/src/client/components/instance-stats.vue b/src/client/components/instance-stats.vue
index 5e7c71ea65..fd0b75609f 100644
--- a/src/client/components/instance-stats.vue
+++ b/src/client/components/instance-stats.vue
@@ -24,35 +24,26 @@
 				<option value="drive-total">{{ $ts._charts.storageUsageTotal }}</option>
 			</optgroup>
 		</MkSelect>
-		<MkSelect v-model="chartSpan" style="margin: 0;">
+		<MkSelect v-model="chartSpan" style="margin: 0 0 0 10px;">
 			<option value="hour">{{ $ts.perHour }}</option>
 			<option value="day">{{ $ts.perDay }}</option>
 		</MkSelect>
 	</div>
-	<canvas ref="chart"></canvas>
+	<MkChart :src="chartSrc" :span="chartSpan" :limit="chartLimit" :detailed="detailed"></MkChart>
 </div>
 </template>
 
 <script lang="ts">
-import { defineComponent, markRaw } from 'vue';
-import Chart from 'chart.js';
-import MkSelect from './form/select.vue';
-import number from '@client/filters/number';
-
-const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b));
-const negate = arr => arr.map(x => -x);
-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 { defineComponent, onMounted, ref, watch } from 'vue';
+import MkSelect from '@client/components/form/select.vue';
+import MkChart from '@client/components/chart.vue';
 import * as os from '@client/os';
+import { defaultStore } from '@client/store';
 
 export default defineComponent({
 	components: {
-		MkSelect
+		MkSelect,
+		MkChart,
 	},
 
 	props: {
@@ -68,463 +59,15 @@ export default defineComponent({
 		},
 	},
 
-	data() {
+	setup() {
+		const chartSpan = ref<'hour' | 'day'>('hour');
+		const chartSrc = ref('notes');
+
 		return {
-			notesLocalWoW: 0,
-			notesLocalDoD: 0,
-			notesRemoteWoW: 0,
-			notesRemoteDoD: 0,
-			usersLocalWoW: 0,
-			usersLocalDoD: 0,
-			usersRemoteWoW: 0,
-			usersRemoteDoD: 0,
-			now: null,
-			chart: null,
-			chartInstance: null,
-			chartSrc: 'notes',
-			chartSpan: 'hour',
-		}
+			chartSrc,
+			chartSpan,
+		};
 	},
-
-	computed: {
-		data(): any {
-			if (this.chart == null) return null;
-			switch (this.chartSrc) {
-				case 'federation-instances': return this.federationInstancesChart(false);
-				case 'federation-instances-total': return this.federationInstancesChart(true);
-				case 'users': return this.usersChart(false);
-				case 'users-total': return this.usersChart(true);
-				case 'active-users': return this.activeUsersChart();
-				case 'notes': return this.notesChart('combined');
-				case 'local-notes': return this.notesChart('local');
-				case 'remote-notes': return this.notesChart('remote');
-				case 'notes-total': return this.notesTotalChart();
-				case 'drive': return this.driveChart();
-				case 'drive-total': return this.driveTotalChart();
-				case 'drive-files': return this.driveFilesChart();
-				case 'drive-files-total': return this.driveFilesTotalChart();
-			}
-		},
-
-		stats(): any[] {
-			const stats =
-				this.chartSpan == 'day' ? this.chart.perDay :
-				this.chartSpan == 'hour' ? this.chart.perHour :
-				null;
-
-			return stats;
-		}
-	},
-
-	watch: {
-		chartSrc() {
-			this.renderChart();
-		},
-
-		chartSpan() {
-			this.renderChart();
-		}
-	},
-
-	async created() {
-		this.now = new Date();
-
-		this.fetchChart();
-	},
-
-	methods: {
-		async fetchChart() {
-			const [perHour, perDay] = await Promise.all([Promise.all([
-				os.api('charts/federation', { limit: this.chartLimit, span: 'hour' }),
-				os.api('charts/users', { limit: this.chartLimit, span: 'hour' }),
-				os.api('charts/active-users', { limit: this.chartLimit, span: 'hour' }),
-				os.api('charts/notes', { limit: this.chartLimit, span: 'hour' }),
-				os.api('charts/drive', { limit: this.chartLimit, span: 'hour' }),
-			]), Promise.all([
-				os.api('charts/federation', { limit: this.chartLimit, span: 'day' }),
-				os.api('charts/users', { limit: this.chartLimit, span: 'day' }),
-				os.api('charts/active-users', { limit: this.chartLimit, span: 'day' }),
-				os.api('charts/notes', { limit: this.chartLimit, span: 'day' }),
-				os.api('charts/drive', { limit: this.chartLimit, span: 'day' }),
-			])]);
-
-			const chart = {
-				perHour: {
-					federation: perHour[0],
-					users: perHour[1],
-					activeUsers: perHour[2],
-					notes: perHour[3],
-					drive: perHour[4],
-				},
-				perDay: {
-					federation: perDay[0],
-					users: perDay[1],
-					activeUsers: perDay[2],
-					notes: perDay[3],
-					drive: perDay[4],
-				}
-			};
-
-			this.chart = chart;
-
-			this.renderChart();
-		},
-
-		renderChart() {
-			if (this.chartInstance) {
-				this.chartInstance.destroy();
-			}
-
-			// TODO: var(--panel)の色が暗いか明るいかで判定する
-			const gridColor = this.$store.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
-
-			Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg');
-			this.chartInstance = markRaw(new Chart(this.$refs.chart, {
-				type: 'line',
-				data: {
-					labels: new Array(this.chartLimit).fill(0).map((_, i) => this.getDate(i).toLocaleString()).slice().reverse(),
-					datasets: this.data.series.map(x => ({
-						label: x.name,
-						data: x.data.slice().reverse(),
-						pointRadius: 0,
-						lineTension: 0,
-						borderWidth: 2,
-						borderColor: x.color,
-						borderDash: x.borderDash || [],
-						backgroundColor: alpha(x.color, 0.1),
-						fill: x.fill == null ? true : x.fill,
-						hidden: !!x.hidden
-					}))
-				},
-				options: {
-					aspectRatio: 2.5,
-					layout: {
-						padding: {
-							left: 16,
-							right: 16,
-							top: 16,
-							bottom: 8
-						}
-					},
-					legend: {
-						position: 'bottom',
-						labels: {
-							boxWidth: 16,
-						}
-					},
-					scales: {
-						xAxes: [{
-							type: 'time',
-							time: {
-								stepSize: 1,
-								unit: this.chartSpan == 'day' ? 'month' : 'day',
-							},
-							gridLines: {
-								display: this.detailed,
-								color: gridColor,
-								zeroLineColor: gridColor,
-							},
-							ticks: {
-								display: this.detailed
-							}
-						}],
-						yAxes: [{
-							position: 'left',
-							gridLines: {
-								color: gridColor,
-								zeroLineColor: gridColor,
-							},
-							ticks: {
-								display: this.detailed
-							}
-						}]
-					},
-					tooltips: {
-						intersect: false,
-						mode: 'index',
-					}
-				}
-			}));
-		},
-
-		getDate(ago: number) {
-			const y = this.now.getFullYear();
-			const m = this.now.getMonth();
-			const d = this.now.getDate();
-			const h = this.now.getHours();
-
-			return this.chartSpan == 'day' ? new Date(y, m, d - ago) : new Date(y, m, d, h - ago);
-		},
-
-		format(arr) {
-			const now = Date.now();
-			return arr.map((v, i) => ({
-				x: new Date(now - ((this.chartSpan == 'day' ? 86400000 :3600000 ) * i)),
-				y: v
-			}));
-		},
-
-		federationInstancesChart(total: boolean): any {
-			return {
-				series: [{
-					name: 'Instances',
-					color: '#008FFB',
-					data: this.format(total
-						? this.stats.federation.instance.total
-						: sum(this.stats.federation.instance.inc, negate(this.stats.federation.instance.dec))
-					)
-				}]
-			};
-		},
-
-		notesChart(type: string): any {
-			return {
-				series: [{
-					name: 'All',
-					type: 'line',
-					color: '#008FFB',
-					borderDash: [5, 5],
-					fill: false,
-					data: this.format(type == 'combined'
-						? sum(this.stats.notes.local.inc, negate(this.stats.notes.local.dec), this.stats.notes.remote.inc, negate(this.stats.notes.remote.dec))
-						: sum(this.stats.notes[type].inc, negate(this.stats.notes[type].dec))
-					)
-				}, {
-					name: 'Renotes',
-					type: 'area',
-					color: '#00E396',
-					data: this.format(type == 'combined'
-						? sum(this.stats.notes.local.diffs.renote, this.stats.notes.remote.diffs.renote)
-						: this.stats.notes[type].diffs.renote
-					)
-				}, {
-					name: 'Replies',
-					type: 'area',
-					color: '#FEB019',
-					data: this.format(type == 'combined'
-						? sum(this.stats.notes.local.diffs.reply, this.stats.notes.remote.diffs.reply)
-						: this.stats.notes[type].diffs.reply
-					)
-				}, {
-					name: 'Normal',
-					type: 'area',
-					color: '#FF4560',
-					data: this.format(type == 'combined'
-						? sum(this.stats.notes.local.diffs.normal, this.stats.notes.remote.diffs.normal)
-						: this.stats.notes[type].diffs.normal
-					)
-				}]
-			};
-		},
-
-		notesTotalChart(): any {
-			return {
-				series: [{
-					name: 'Combined',
-					type: 'line',
-					color: '#008FFB',
-					data: this.format(sum(this.stats.notes.local.total, this.stats.notes.remote.total))
-				}, {
-					name: 'Local',
-					type: 'area',
-					color: '#008FFB',
-					hidden: true,
-					data: this.format(this.stats.notes.local.total)
-				}, {
-					name: 'Remote',
-					type: 'area',
-					color: '#008FFB',
-					hidden: true,
-					data: this.format(this.stats.notes.remote.total)
-				}]
-			};
-		},
-
-		usersChart(total: boolean): any {
-			return {
-				series: [{
-					name: 'Combined',
-					type: 'line',
-					color: '#008FFB',
-					data: this.format(total
-						? sum(this.stats.users.local.total, this.stats.users.remote.total)
-						: sum(this.stats.users.local.inc, negate(this.stats.users.local.dec), this.stats.users.remote.inc, negate(this.stats.users.remote.dec))
-					)
-				}, {
-					name: 'Local',
-					type: 'area',
-					color: '#008FFB',
-					hidden: true,
-					data: this.format(total
-						? this.stats.users.local.total
-						: sum(this.stats.users.local.inc, negate(this.stats.users.local.dec))
-					)
-				}, {
-					name: 'Remote',
-					type: 'area',
-					color: '#008FFB',
-					hidden: true,
-					data: this.format(total
-						? this.stats.users.remote.total
-						: sum(this.stats.users.remote.inc, negate(this.stats.users.remote.dec))
-					)
-				}]
-			};
-		},
-
-		activeUsersChart(): any {
-			return {
-				series: [{
-					name: 'Combined',
-					type: 'line',
-					color: '#008FFB',
-					data: this.format(sum(this.stats.activeUsers.local.count, this.stats.activeUsers.remote.count))
-				}, {
-					name: 'Local',
-					type: 'area',
-					color: '#008FFB',
-					hidden: true,
-					data: this.format(this.stats.activeUsers.local.count)
-				}, {
-					name: 'Remote',
-					type: 'area',
-					color: '#008FFB',
-					hidden: true,
-					data: this.format(this.stats.activeUsers.remote.count)
-				}]
-			};
-		},
-
-		driveChart(): any {
-			return {
-				bytes: true,
-				series: [{
-					name: 'All',
-					type: 'line',
-					color: '#09d8e2',
-					borderDash: [5, 5],
-					fill: false,
-					data: this.format(
-						sum(
-							this.stats.drive.local.incSize,
-							negate(this.stats.drive.local.decSize),
-							this.stats.drive.remote.incSize,
-							negate(this.stats.drive.remote.decSize)
-						)
-					)
-				}, {
-					name: 'Local +',
-					type: 'area',
-					color: '#008FFB',
-					data: this.format(this.stats.drive.local.incSize)
-				}, {
-					name: 'Local -',
-					type: 'area',
-					color: '#FF4560',
-					data: this.format(negate(this.stats.drive.local.decSize))
-				}, {
-					name: 'Remote +',
-					type: 'area',
-					color: '#00E396',
-					data: this.format(this.stats.drive.remote.incSize)
-				}, {
-					name: 'Remote -',
-					type: 'area',
-					color: '#FEB019',
-					data: this.format(negate(this.stats.drive.remote.decSize))
-				}]
-			};
-		},
-
-		driveTotalChart(): any {
-			return {
-				bytes: true,
-				series: [{
-					name: 'Combined',
-					type: 'line',
-					color: '#008FFB',
-					data: this.format(sum(this.stats.drive.local.totalSize, this.stats.drive.remote.totalSize))
-				}, {
-					name: 'Local',
-					type: 'area',
-					color: '#008FFB',
-					hidden: true,
-					data: this.format(this.stats.drive.local.totalSize)
-				}, {
-					name: 'Remote',
-					type: 'area',
-					color: '#008FFB',
-					hidden: true,
-					data: this.format(this.stats.drive.remote.totalSize)
-				}]
-			};
-		},
-
-		driveFilesChart(): any {
-			return {
-				series: [{
-					name: 'All',
-					type: 'line',
-					color: '#09d8e2',
-					borderDash: [5, 5],
-					fill: false,
-					data: this.format(
-						sum(
-							this.stats.drive.local.incCount,
-							negate(this.stats.drive.local.decCount),
-							this.stats.drive.remote.incCount,
-							negate(this.stats.drive.remote.decCount)
-						)
-					)
-				}, {
-					name: 'Local +',
-					type: 'area',
-					color: '#008FFB',
-					data: this.format(this.stats.drive.local.incCount)
-				}, {
-					name: 'Local -',
-					type: 'area',
-					color: '#FF4560',
-					data: this.format(negate(this.stats.drive.local.decCount))
-				}, {
-					name: 'Remote +',
-					type: 'area',
-					color: '#00E396',
-					data: this.format(this.stats.drive.remote.incCount)
-				}, {
-					name: 'Remote -',
-					type: 'area',
-					color: '#FEB019',
-					data: this.format(negate(this.stats.drive.remote.decCount))
-				}]
-			};
-		},
-
-		driveFilesTotalChart(): any {
-			return {
-				series: [{
-					name: 'Combined',
-					type: 'line',
-					color: '#008FFB',
-					data: this.format(sum(this.stats.drive.local.totalCount, this.stats.drive.remote.totalCount))
-				}, {
-					name: 'Local',
-					type: 'area',
-					color: '#008FFB',
-					hidden: true,
-					data: this.format(this.stats.drive.local.totalCount)
-				}, {
-					name: 'Remote',
-					type: 'area',
-					color: '#008FFB',
-					hidden: true,
-					data: this.format(this.stats.drive.remote.totalCount)
-				}]
-			};
-		},
-
-		number
-	}
 });
 </script>
 
diff --git a/src/client/components/number-diff.vue b/src/client/components/number-diff.vue
new file mode 100644
index 0000000000..ba7e6964de
--- /dev/null
+++ b/src/client/components/number-diff.vue
@@ -0,0 +1,47 @@
+<template>
+<span class="ceaaebcd" :class="{ isPlus, isMinus, isZero }">
+	<slot name="before"></slot>{{ isPlus ? '+' : isMinus ? '-' : '' }}{{ number(value) }}<slot name="after"></slot>
+</span>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent } from 'vue';
+import number from '@client/filters/number';
+
+export default defineComponent({
+	props: {
+		value: {
+			type: Number,
+			required: true
+		},
+	},
+
+	setup(props) {
+		const isPlus = computed(() => props.value > 0);
+		const isMinus = computed(() => props.value < 0);
+		const isZero = computed(() => props.value === 0);
+		return {
+			isPlus,
+			isMinus,
+			isZero,
+			number,
+		};
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.ceaaebcd {
+	&.isPlus {
+		color: var(--success);
+	}
+
+	&.isMinus {
+		color: var(--error);
+	}
+
+	&.isZero {
+		opacity: 0.5;
+	}
+}
+</style>
diff --git a/src/client/components/ui/menu.vue b/src/client/components/ui/menu.vue
index da24d90170..aaef527f1a 100644
--- a/src/client/components/ui/menu.vue
+++ b/src/client/components/ui/menu.vue
@@ -1,5 +1,6 @@
 <template>
 <div class="rrevdjwt" :class="{ center: align === 'center' }"
+	:style="{ width: width ? width + 'px' : null }"
 	ref="items"
 	@contextmenu.self="e => e.preventDefault()"
 	v-hotkey="keymap"
@@ -59,6 +60,10 @@ export default defineComponent({
 			type: String,
 			requried: false
 		},
+		width: {
+			type: Number,
+			required: false
+		},
 	},
 	emits: ['close'],
 	data() {
diff --git a/src/client/components/ui/popup-menu.vue b/src/client/components/ui/popup-menu.vue
index 23f7c89f3b..3ff4c658b1 100644
--- a/src/client/components/ui/popup-menu.vue
+++ b/src/client/components/ui/popup-menu.vue
@@ -1,6 +1,6 @@
 <template>
 <MkPopup ref="popup" :src="src" @closed="$emit('closed')">
-	<MkMenu :items="items" :align="align" @close="$refs.popup.close()" class="_popup _shadow"/>
+	<MkMenu :items="items" :align="align" :width="width" @close="$refs.popup.close()" class="_popup _shadow"/>
 </MkPopup>
 </template>
 
@@ -24,6 +24,10 @@ export default defineComponent({
 			type: String,
 			required: false
 		},
+		width: {
+			type: Number,
+			required: false
+		},
 		viaKeyboard: {
 			type: Boolean,
 			required: false
diff --git a/src/client/os.ts b/src/client/os.ts
index 7ae774dd92..743d2d131f 100644
--- a/src/client/os.ts
+++ b/src/client/os.ts
@@ -372,12 +372,17 @@ export async function openEmojiPicker(src?: HTMLElement, opts, initialTextarea:
 	});
 }
 
-export function popupMenu(items: any[] | Ref<any[]>, src?: HTMLElement, options?: { align?: string; viaKeyboard?: boolean }) {
+export function popupMenu(items: any[] | Ref<any[]>, src?: HTMLElement, options?: {
+	align?: string;
+	width?: number;
+	viaKeyboard?: boolean;
+}) {
 	return new Promise((resolve, reject) => {
 		let dispose;
 		popup(import('@client/components/ui/popup-menu.vue'), {
 			items,
 			src,
+			width: options?.width,
 			align: options?.align,
 			viaKeyboard: options?.viaKeyboard
 		}, {
diff --git a/src/client/pages/instance-info.vue b/src/client/pages/instance-info.vue
index 4fbf104f0c..7a4cd5f016 100644
--- a/src/client/pages/instance-info.vue
+++ b/src/client/pages/instance-info.vue
@@ -65,17 +65,17 @@
 			<div class="_debobigegoPanel cmhjzshl">
 				<div class="selects">
 					<MkSelect v-model="chartSrc" style="margin: 0; flex: 1;">
-						<option value="requests">{{ $ts._instanceCharts.requests }}</option>
-						<option value="users">{{ $ts._instanceCharts.users }}</option>
-						<option value="users-total">{{ $ts._instanceCharts.usersTotal }}</option>
-						<option value="notes">{{ $ts._instanceCharts.notes }}</option>
-						<option value="notes-total">{{ $ts._instanceCharts.notesTotal }}</option>
-						<option value="ff">{{ $ts._instanceCharts.ff }}</option>
-						<option value="ff-total">{{ $ts._instanceCharts.ffTotal }}</option>
-						<option value="drive-usage">{{ $ts._instanceCharts.cacheSize }}</option>
-						<option value="drive-usage-total">{{ $ts._instanceCharts.cacheSizeTotal }}</option>
-						<option value="drive-files">{{ $ts._instanceCharts.files }}</option>
-						<option value="drive-files-total">{{ $ts._instanceCharts.filesTotal }}</option>
+						<option value="instance-requests">{{ $ts._instanceCharts.requests }}</option>
+						<option value="instance-users">{{ $ts._instanceCharts.users }}</option>
+						<option value="instance-users-total">{{ $ts._instanceCharts.usersTotal }}</option>
+						<option value="instance-notes">{{ $ts._instanceCharts.notes }}</option>
+						<option value="instance-notes-total">{{ $ts._instanceCharts.notesTotal }}</option>
+						<option value="instance-ff">{{ $ts._instanceCharts.ff }}</option>
+						<option value="instance-ff-total">{{ $ts._instanceCharts.ffTotal }}</option>
+						<option value="instance-drive-usage">{{ $ts._instanceCharts.cacheSize }}</option>
+						<option value="instance-drive-usage-total">{{ $ts._instanceCharts.cacheSizeTotal }}</option>
+						<option value="instance-drive-files">{{ $ts._instanceCharts.files }}</option>
+						<option value="instance-drive-files-total">{{ $ts._instanceCharts.filesTotal }}</option>
 					</MkSelect>
 					<MkSelect v-model="chartSpan" style="margin: 0;">
 						<option value="hour">{{ $ts.perHour }}</option>
@@ -83,7 +83,7 @@
 					</MkSelect>
 				</div>
 				<div class="chart">
-					<canvas :ref="setChart"></canvas>
+					<MkChart :src="chartSrc" :span="chartSpan" :limit="90" :detailed="true"></MkChart>
 				</div>
 			</div>
 		</div>
@@ -135,7 +135,7 @@
 
 <script lang="ts">
 import { defineAsyncComponent, defineComponent } from 'vue';
-import Chart from 'chart.js';
+import MkChart from '@client/components/chart.vue';
 import FormObjectView from '@client/components/debobigego/object-view.vue';
 import FormTextarea from '@client/components/debobigego/textarea.vue';
 import FormLink from '@client/components/debobigego/link.vue';
@@ -151,17 +151,6 @@ import bytes from '@client/filters/bytes';
 import * as symbols from '@client/symbols';
 import MkInstanceInfo from '@client/pages/instance/instance.vue';
 
-const chartLimit = 90;
-const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b));
-const negate = arr => arr.map(x => -x);
-const alpha = hex => {
-	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}, 0.1)`;
-};
-
 export default defineComponent({
 	components: {
 		FormBase,
@@ -173,6 +162,7 @@ export default defineComponent({
 		FormKeyValueView,
 		FormSuspense,
 		MkSelect,
+		MkChart,
 	},
 
 	props: {
@@ -199,53 +189,11 @@ export default defineComponent({
 			dnsPromiseFactory: () => os.api('federation/dns', {
 				host: this.host
 			}),
-			now: null,
-			canvas: null,
-			chart: null,
-			chartInstance: null,
-			chartSrc: 'requests',
+			chartSrc: 'instance-requests',
 			chartSpan: 'hour',
 		}
 	},
 
-	computed: {
-		data(): any {
-			if (this.chart == null) return null;
-			switch (this.chartSrc) {
-				case 'requests': return this.requestsChart();
-				case 'users': return this.usersChart(false);
-				case 'users-total': return this.usersChart(true);
-				case 'notes': return this.notesChart(false);
-				case 'notes-total': return this.notesChart(true);
-				case 'ff': return this.ffChart(false);
-				case 'ff-total': return this.ffChart(true);
-				case 'drive-usage': return this.driveUsageChart(false);
-				case 'drive-usage-total': return this.driveUsageChart(true);
-				case 'drive-files': return this.driveFilesChart(false);
-				case 'drive-files-total': return this.driveFilesChart(true);
-			}
-		},
-
-		stats(): any[] {
-			const stats =
-				this.chartSpan == 'day' ? this.chart.perDay :
-				this.chartSpan == 'hour' ? this.chart.perHour :
-				null;
-
-			return stats;
-		},
-	},
-
-	watch: {
-		chartSrc() {
-			this.renderChart();
-		},
-
-		chartSpan() {
-			this.renderChart();
-		}
-	},
-
 	mounted() {
 		this.fetch();
 	},
@@ -258,190 +206,6 @@ export default defineComponent({
 			this.instance = await os.api('federation/show-instance', {
 				host: this.host
 			});
-
-			this.now = new Date();
-
-			const [perHour, perDay] = await Promise.all([
-				os.api('charts/instance', { host: this.instance.host, limit: chartLimit, span: 'hour' }),
-				os.api('charts/instance', { host: this.instance.host, limit: chartLimit, span: 'day' }),
-			]);
-
-			const chart = {
-				perHour: perHour,
-				perDay: perDay
-			};
-
-			this.chart = chart;
-
-			this.renderChart();
-		},
-
-		setChart(el) {
-			this.canvas = el;
-		},
-
-		renderChart() {
-			if (this.chartInstance) {
-				this.chartInstance.destroy();
-			}
-
-			Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg');
-			this.chartInstance = new Chart(this.canvas, {
-				type: 'line',
-				data: {
-					labels: new Array(chartLimit).fill(0).map((_, i) => this.getDate(i).toLocaleString()).slice().reverse(),
-					datasets: this.data.series.map(x => ({
-						label: x.name,
-						data: x.data.slice().reverse(),
-						pointRadius: 0,
-						lineTension: 0,
-						borderWidth: 2,
-						borderColor: x.color,
-						backgroundColor: alpha(x.color),
-					}))
-				},
-				options: {
-					aspectRatio: 2.5,
-					layout: {
-						padding: {
-							left: 16,
-							right: 16,
-							top: 16,
-							bottom: 16
-						}
-					},
-					legend: {
-						position: 'bottom',
-						labels: {
-							boxWidth: 16,
-						}
-					},
-					scales: {
-						xAxes: [{
-							gridLines: {
-								display: false
-							},
-							ticks: {
-								display: false
-							}
-						}],
-						yAxes: [{
-							position: 'right',
-							ticks: {
-								display: false
-							}
-						}]
-					},
-					tooltips: {
-						intersect: false,
-						mode: 'index',
-					}
-				}
-			});
-		},
-
-		getDate(ago: number) {
-			const y = this.now.getFullYear();
-			const m = this.now.getMonth();
-			const d = this.now.getDate();
-			const h = this.now.getHours();
-
-			return this.chartSpan == 'day' ? new Date(y, m, d - ago) : new Date(y, m, d, h - ago);
-		},
-
-		format(arr) {
-			return arr;
-		},
-
-		requestsChart(): any {
-			return {
-				series: [{
-					name: 'In',
-					color: '#008FFB',
-					data: this.format(this.stats.requests.received)
-				}, {
-					name: 'Out (succ)',
-					color: '#00E396',
-					data: this.format(this.stats.requests.succeeded)
-				}, {
-					name: 'Out (fail)',
-					color: '#FEB019',
-					data: this.format(this.stats.requests.failed)
-				}]
-			};
-		},
-
-		usersChart(total: boolean): any {
-			return {
-				series: [{
-					name: 'Users',
-					color: '#008FFB',
-					data: this.format(total
-						? this.stats.users.total
-						: sum(this.stats.users.inc, negate(this.stats.users.dec))
-					)
-				}]
-			};
-		},
-
-		notesChart(total: boolean): any {
-			return {
-				series: [{
-					name: 'Notes',
-					color: '#008FFB',
-					data: this.format(total
-						? this.stats.notes.total
-						: sum(this.stats.notes.inc, negate(this.stats.notes.dec))
-					)
-				}]
-			};
-		},
-
-		ffChart(total: boolean): any {
-			return {
-				series: [{
-					name: 'Following',
-					color: '#008FFB',
-					data: this.format(total
-						? this.stats.following.total
-						: sum(this.stats.following.inc, negate(this.stats.following.dec))
-					)
-				}, {
-					name: 'Followers',
-					color: '#00E396',
-					data: this.format(total
-						? this.stats.followers.total
-						: sum(this.stats.followers.inc, negate(this.stats.followers.dec))
-					)
-				}]
-			};
-		},
-
-		driveUsageChart(total: boolean): any {
-			return {
-				bytes: true,
-				series: [{
-					name: 'Drive usage',
-					color: '#008FFB',
-					data: this.format(total
-						? this.stats.drive.totalUsage
-						: sum(this.stats.drive.incUsage, negate(this.stats.drive.decUsage))
-					)
-				}]
-			};
-		},
-
-		driveFilesChart(total: boolean): any {
-			return {
-				series: [{
-					name: 'Drive files',
-					color: '#008FFB',
-					data: this.format(total
-						? this.stats.drive.totalFiles
-						: sum(this.stats.drive.incFiles, negate(this.stats.drive.decFiles))
-					)
-				}]
-			};
 		},
 
 		info() {
diff --git a/src/client/pages/instance/instance.vue b/src/client/pages/instance/instance.vue
index 6117f090de..5572fbbf75 100644
--- a/src/client/pages/instance/instance.vue
+++ b/src/client/pages/instance/instance.vue
@@ -78,17 +78,17 @@
 				<span class="label">{{ $ts.charts }}</span>
 				<div class="selects">
 					<MkSelect v-model="chartSrc" style="margin: 0; flex: 1;">
-						<option value="requests">{{ $ts._instanceCharts.requests }}</option>
-						<option value="users">{{ $ts._instanceCharts.users }}</option>
-						<option value="users-total">{{ $ts._instanceCharts.usersTotal }}</option>
-						<option value="notes">{{ $ts._instanceCharts.notes }}</option>
-						<option value="notes-total">{{ $ts._instanceCharts.notesTotal }}</option>
-						<option value="ff">{{ $ts._instanceCharts.ff }}</option>
-						<option value="ff-total">{{ $ts._instanceCharts.ffTotal }}</option>
-						<option value="drive-usage">{{ $ts._instanceCharts.cacheSize }}</option>
-						<option value="drive-usage-total">{{ $ts._instanceCharts.cacheSizeTotal }}</option>
-						<option value="drive-files">{{ $ts._instanceCharts.files }}</option>
-						<option value="drive-files-total">{{ $ts._instanceCharts.filesTotal }}</option>
+						<option value="instance-requests">{{ $ts._instanceCharts.requests }}</option>
+						<option value="instance-users">{{ $ts._instanceCharts.users }}</option>
+						<option value="instance-users-total">{{ $ts._instanceCharts.usersTotal }}</option>
+						<option value="instance-notes">{{ $ts._instanceCharts.notes }}</option>
+						<option value="instance-notes-total">{{ $ts._instanceCharts.notesTotal }}</option>
+						<option value="instance-ff">{{ $ts._instanceCharts.ff }}</option>
+						<option value="instance-ff-total">{{ $ts._instanceCharts.ffTotal }}</option>
+						<option value="instance-drive-usage">{{ $ts._instanceCharts.cacheSize }}</option>
+						<option value="instance-drive-usage-total">{{ $ts._instanceCharts.cacheSizeTotal }}</option>
+						<option value="instance-drive-files">{{ $ts._instanceCharts.files }}</option>
+						<option value="instance-drive-files-total">{{ $ts._instanceCharts.filesTotal }}</option>
 					</MkSelect>
 					<MkSelect v-model="chartSpan" style="margin: 0;">
 						<option value="hour">{{ $ts.perHour }}</option>
@@ -97,7 +97,7 @@
 				</div>
 			</div>
 			<div class="chart">
-				<canvas :ref="setChart"></canvas>
+				<MkChart :src="chartSrc" :span="chartSpan" :limit="90" :detailed="true"></MkChart>
 			</div>
 		</div>
 		<div class="operations section">
@@ -124,28 +124,17 @@
 
 <script lang="ts">
 import { defineComponent, markRaw } from 'vue';
-import Chart from 'chart.js';
 import XModalWindow from '@client/components/ui/modal-window.vue';
 import MkUsersDialog from '@client/components/users-dialog.vue';
 import MkSelect from '@client/components/form/select.vue';
 import MkButton from '@client/components/ui/button.vue';
 import MkSwitch from '@client/components/form/switch.vue';
 import MkInfo from '@client/components/ui/info.vue';
+import MkChart from '@client/components/chart.vue';
 import bytes from '@client/filters/bytes';
 import number from '@client/filters/number';
 import * as os from '@client/os';
 
-const chartLimit = 90;
-const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b));
-const negate = arr => arr.map(x => -x);
-const alpha = hex => {
-	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}, 0.1)`;
-};
-
 export default defineComponent({
 	components: {
 		XModalWindow,
@@ -153,6 +142,7 @@ export default defineComponent({
 		MkButton,
 		MkSwitch,
 		MkInfo,
+		MkChart,
 	},
 
 	props: {
@@ -167,42 +157,12 @@ export default defineComponent({
 	data() {
 		return {
 			isSuspended: this.instance.isSuspended,
-			now: null,
-			canvas: null,
-			chart: null,
-			chartInstance: null,
 			chartSrc: 'requests',
 			chartSpan: 'hour',
 		};
 	},
 
 	computed: {
-		data(): any {
-			if (this.chart == null) return null;
-			switch (this.chartSrc) {
-				case 'requests': return this.requestsChart();
-				case 'users': return this.usersChart(false);
-				case 'users-total': return this.usersChart(true);
-				case 'notes': return this.notesChart(false);
-				case 'notes-total': return this.notesChart(true);
-				case 'ff': return this.ffChart(false);
-				case 'ff-total': return this.ffChart(true);
-				case 'drive-usage': return this.driveUsageChart(false);
-				case 'drive-usage-total': return this.driveUsageChart(true);
-				case 'drive-files': return this.driveFilesChart(false);
-				case 'drive-files-total': return this.driveFilesChart(true);
-			}
-		},
-
-		stats(): any[] {
-			const stats =
-				this.chartSpan == 'day' ? this.chart.perDay :
-				this.chartSpan == 'hour' ? this.chart.perHour :
-				null;
-
-			return stats;
-		},
-
 		meta() {
 			return this.$instance;
 		},
@@ -219,49 +179,15 @@ export default defineComponent({
 				isSuspended: this.isSuspended
 			});
 		},
-
-		chartSrc() {
-			this.renderChart();
-		},
-
-		chartSpan() {
-			this.renderChart();
-		}
-	},
-
-	async created() {
-		this.now = new Date();
-
-		const [perHour, perDay] = await Promise.all([
-			os.api('charts/instance', { host: this.instance.host, limit: chartLimit, span: 'hour' }),
-			os.api('charts/instance', { host: this.instance.host, limit: chartLimit, span: 'day' }),
-		]);
-
-		const chart = {
-			perHour: perHour,
-			perDay: perDay
-		};
-
-		this.chart = chart;
-
-		this.renderChart();
 	},
 
 	methods: {
-		setChart(el) {
-			this.canvas = el;
-		},
-
 		changeBlock(e) {
 			os.api('admin/update-meta', {
 				blockedHosts: this.isBlocked ? this.meta.blockedHosts.concat([this.instance.host]) : this.meta.blockedHosts.filter(x => x !== this.instance.host)
 			});
 		},
 
-		setSrc(src) {
-			this.chartSrc = src;
-		},
-
 		removeAllFollowing() {
 			os.apiWithDialog('admin/federation/remove-all-following', {
 				host: this.instance.host
@@ -274,170 +200,6 @@ export default defineComponent({
 			});
 		},
 
-		renderChart() {
-			if (this.chartInstance) {
-				this.chartInstance.destroy();
-			}
-
-			Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg');
-			this.chartInstance = markRaw(new Chart(this.canvas, {
-				type: 'line',
-				data: {
-					labels: new Array(chartLimit).fill(0).map((_, i) => this.getDate(i).toLocaleString()).slice().reverse(),
-					datasets: this.data.series.map(x => ({
-						label: x.name,
-						data: x.data.slice().reverse(),
-						pointRadius: 0,
-						lineTension: 0,
-						borderWidth: 2,
-						borderColor: x.color,
-						backgroundColor: alpha(x.color),
-					}))
-				},
-				options: {
-					aspectRatio: 2.5,
-					layout: {
-						padding: {
-							left: 16,
-							right: 16,
-							top: 16,
-							bottom: 0
-						}
-					},
-					legend: {
-						position: 'bottom',
-						labels: {
-							boxWidth: 16,
-						}
-					},
-					scales: {
-						xAxes: [{
-							gridLines: {
-								display: false
-							},
-							ticks: {
-								display: false
-							}
-						}],
-						yAxes: [{
-							position: 'right',
-							ticks: {
-								display: false
-							}
-						}]
-					},
-					tooltips: {
-						intersect: false,
-						mode: 'index',
-					}
-				}
-			}));
-		},
-
-		getDate(ago: number) {
-			const y = this.now.getFullYear();
-			const m = this.now.getMonth();
-			const d = this.now.getDate();
-			const h = this.now.getHours();
-
-			return this.chartSpan == 'day' ? new Date(y, m, d - ago) : new Date(y, m, d, h - ago);
-		},
-
-		format(arr) {
-			return arr;
-		},
-
-		requestsChart(): any {
-			return {
-				series: [{
-					name: 'In',
-					color: '#008FFB',
-					data: this.format(this.stats.requests.received)
-				}, {
-					name: 'Out (succ)',
-					color: '#00E396',
-					data: this.format(this.stats.requests.succeeded)
-				}, {
-					name: 'Out (fail)',
-					color: '#FEB019',
-					data: this.format(this.stats.requests.failed)
-				}]
-			};
-		},
-
-		usersChart(total: boolean): any {
-			return {
-				series: [{
-					name: 'Users',
-					color: '#008FFB',
-					data: this.format(total
-						? this.stats.users.total
-						: sum(this.stats.users.inc, negate(this.stats.users.dec))
-					)
-				}]
-			};
-		},
-
-		notesChart(total: boolean): any {
-			return {
-				series: [{
-					name: 'Notes',
-					color: '#008FFB',
-					data: this.format(total
-						? this.stats.notes.total
-						: sum(this.stats.notes.inc, negate(this.stats.notes.dec))
-					)
-				}]
-			};
-		},
-
-		ffChart(total: boolean): any {
-			return {
-				series: [{
-					name: 'Following',
-					color: '#008FFB',
-					data: this.format(total
-						? this.stats.following.total
-						: sum(this.stats.following.inc, negate(this.stats.following.dec))
-					)
-				}, {
-					name: 'Followers',
-					color: '#00E396',
-					data: this.format(total
-						? this.stats.followers.total
-						: sum(this.stats.followers.inc, negate(this.stats.followers.dec))
-					)
-				}]
-			};
-		},
-
-		driveUsageChart(total: boolean): any {
-			return {
-				bytes: true,
-				series: [{
-					name: 'Drive usage',
-					color: '#008FFB',
-					data: this.format(total
-						? this.stats.drive.totalUsage
-						: sum(this.stats.drive.incUsage, negate(this.stats.drive.decUsage))
-					)
-				}]
-			};
-		},
-
-		driveFilesChart(total: boolean): any {
-			return {
-				series: [{
-					name: 'Drive files',
-					color: '#008FFB',
-					data: this.format(total
-						? this.stats.drive.totalFiles
-						: sum(this.stats.drive.incFiles, negate(this.stats.drive.decFiles))
-					)
-				}]
-			};
-		},
-
 		showFollowing() {
 			os.modal(MkUsersDialog, {
 				title: this.$ts.instanceFollowing,
diff --git a/src/client/pages/instance/metrics.vue b/src/client/pages/instance/metrics.vue
index 1606063aee..da36f6c688 100644
--- a/src/client/pages/instance/metrics.vue
+++ b/src/client/pages/instance/metrics.vue
@@ -52,7 +52,21 @@
 
 <script lang="ts">
 import { defineComponent, markRaw } from 'vue';
-import Chart from 'chart.js';
+import {
+  Chart,
+  ArcElement,
+  LineElement,
+  BarElement,
+  PointElement,
+  BarController,
+  LineController,
+  CategoryScale,
+  LinearScale,
+  Legend,
+  Title,
+  Tooltip,
+  SubTitle
+} from 'chart.js';
 import MkButton from '@client/components/ui/button.vue';
 import MkSelect from '@client/components/form/select.vue';
 import MkInput from '@client/components/form/input.vue';
@@ -64,6 +78,21 @@ import bytes from '@client/filters/bytes';
 import number from '@client/filters/number';
 import MkInstanceInfo from './instance.vue';
 
+Chart.register(
+  ArcElement,
+  LineElement,
+  BarElement,
+  PointElement,
+  BarController,
+  LineController,
+  CategoryScale,
+  LinearScale,
+  Legend,
+  Title,
+  Tooltip,
+  SubTitle
+);
+
 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);
@@ -116,7 +145,7 @@ export default defineComponent({
 	mounted() {
 		this.fetchJobs();
 
-		Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+		Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
 
 		os.api('admin/server-info', {}).then(res => {
 			this.serverInfo = res;
@@ -157,7 +186,7 @@ export default defineComponent({
 					datasets: [{
 						label: 'CPU',
 						pointRadius: 0,
-						lineTension: 0,
+						tension: 0,
 						borderWidth: 2,
 						borderColor: '#86b300',
 						backgroundColor: alpha('#86b300', 0.1),
@@ -165,7 +194,7 @@ export default defineComponent({
 					}, {
 						label: 'MEM (active)',
 						pointRadius: 0,
-						lineTension: 0,
+						tension: 0,
 						borderWidth: 2,
 						borderColor: '#935dbf',
 						backgroundColor: alpha('#935dbf', 0.02),
@@ -173,7 +202,7 @@ export default defineComponent({
 					}, {
 						label: 'MEM (used)',
 						pointRadius: 0,
-						lineTension: 0,
+						tension: 0,
 						borderWidth: 2,
 						borderColor: '#935dbf',
 						borderDash: [5, 5],
@@ -198,7 +227,7 @@ export default defineComponent({
 						}
 					},
 					scales: {
-						xAxes: [{
+						x: {
 							gridLines: {
 								display: false,
 								color: this.gridColor,
@@ -207,8 +236,8 @@ export default defineComponent({
 							ticks: {
 								display: false,
 							}
-						}],
-						yAxes: [{
+						},
+						y: {
 							position: 'right',
 							gridLines: {
 								display: true,
@@ -219,7 +248,7 @@ export default defineComponent({
 								display: false,
 								max: 100
 							}
-						}]
+						}
 					},
 					tooltips: {
 						intersect: false,
@@ -238,7 +267,7 @@ export default defineComponent({
 					datasets: [{
 						label: 'In',
 						pointRadius: 0,
-						lineTension: 0,
+						tension: 0,
 						borderWidth: 2,
 						borderColor: '#94a029',
 						backgroundColor: alpha('#94a029', 0.1),
@@ -246,7 +275,7 @@ export default defineComponent({
 					}, {
 						label: 'Out',
 						pointRadius: 0,
-						lineTension: 0,
+						tension: 0,
 						borderWidth: 2,
 						borderColor: '#ff9156',
 						backgroundColor: alpha('#ff9156', 0.1),
@@ -270,7 +299,7 @@ export default defineComponent({
 						}
 					},
 					scales: {
-						xAxes: [{
+						x: {
 							gridLines: {
 								display: false,
 								color: this.gridColor,
@@ -279,8 +308,8 @@ export default defineComponent({
 							ticks: {
 								display: false
 							}
-						}],
-						yAxes: [{
+						},
+						y: {
 							position: 'right',
 							gridLines: {
 								display: true,
@@ -290,7 +319,7 @@ export default defineComponent({
 							ticks: {
 								display: false,
 							}
-						}]
+						}
 					},
 					tooltips: {
 						intersect: false,
@@ -309,7 +338,7 @@ export default defineComponent({
 					datasets: [{
 						label: 'Read',
 						pointRadius: 0,
-						lineTension: 0,
+						tension: 0,
 						borderWidth: 2,
 						borderColor: '#94a029',
 						backgroundColor: alpha('#94a029', 0.1),
@@ -317,7 +346,7 @@ export default defineComponent({
 					}, {
 						label: 'Write',
 						pointRadius: 0,
-						lineTension: 0,
+						tension: 0,
 						borderWidth: 2,
 						borderColor: '#ff9156',
 						backgroundColor: alpha('#ff9156', 0.1),
@@ -341,7 +370,7 @@ export default defineComponent({
 						}
 					},
 					scales: {
-						xAxes: [{
+						x: {
 							gridLines: {
 								display: false,
 								color: this.gridColor,
@@ -350,8 +379,8 @@ export default defineComponent({
 							ticks: {
 								display: false
 							}
-						}],
-						yAxes: [{
+						},
+						y: {
 							position: 'right',
 							gridLines: {
 								display: true,
@@ -361,7 +390,7 @@ export default defineComponent({
 							ticks: {
 								display: false,
 							}
-						}]
+						}
 					},
 					tooltips: {
 						intersect: false,
@@ -371,18 +400,6 @@ export default defineComponent({
 			}));
 		},
 
-		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;
diff --git a/src/client/pages/instance/overview.vue b/src/client/pages/instance/overview.vue
index c6db9d0c04..4a01eeb751 100644
--- a/src/client/pages/instance/overview.vue
+++ b/src/client/pages/instance/overview.vue
@@ -1,61 +1,67 @@
 <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="_debobigegoItem">
-			<div class="_debobigegoPanel">
-				<MkInstanceStats :chart-limit="300" :detailed="true"/>
+<div>
+	<MkHeader :info="header"/>
+
+	<div class="edbbcaef">
+		<div class="numbers" v-if="stats">
+			<div class="number _panel">
+				<div class="label">Users</div>
+				<div class="value _monospace">
+					{{ number(stats.originalUsersCount) }}
+					<MkNumberDiff v-if="usersComparedToThePrevDay" class="diff" :value="usersComparedToThePrevDay" v-tooltip="$ts.dayOverDayChanges"><template #before>(</template><template #after>)</template></MkNumberDiff>
+				</div>
+			</div>
+			<div class="number _panel">
+				<div class="label">Notes</div>
+				<div class="value _monospace">
+					{{ number(stats.originalNotesCount) }}
+					<MkNumberDiff v-if="notesComparedToThePrevDay" class="diff" :value="notesComparedToThePrevDay" v-tooltip="$ts.dayOverDayChanges"><template #before>(</template><template #after>)</template></MkNumberDiff>
+				</div>
 			</div>
 		</div>
 
-		<XMetrics/>
+		<MkContainer :foldable="true" class="charts">
+			<template #header><i class="fas fa-chart-bar"></i>{{ $ts.charts }}</template>
+			<div style="padding-top: 12px;">
+				<MkInstanceStats :chart-limit="500" :detailed="true"/>
+			</div>
+		</MkContainer>
+		
+			<!--<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>
+		<div class="numbers">
+			<div class="number _panel">
+				<div class="label">Misskey</div>
+				<div class="value _monospace">{{ version }}</div>
+			</div>
+			<div class="number _panel" v-if="serverInfo">
+				<div class="label">Node.js</div>
+				<div class="value _monospace">{{ serverInfo.node }}</div>
+			</div>
+			<div class="number _panel" v-if="serverInfo">
+				<div class="label">PostgreSQL</div>
+				<div class="value _monospace">{{ serverInfo.psql }}</div>
+			</div>
+			<div class="number _panel" v-if="serverInfo">
+				<div class="label">Redis</div>
+				<div class="value _monospace">{{ serverInfo.redis }}</div>
+			</div>
+			<div class="number _panel">
+				<div class="label">Vue</div>
+				<div class="value _monospace">{{ vueVersion }}</div>
+			</div>
+		</div>
+	</div>
+</div>
 </template>
 
 <script lang="ts">
-import { computed, defineComponent, markRaw } from 'vue';
+import { computed, defineComponent, version as vueVersion } from 'vue';
 import FormKeyValueView from '@client/components/debobigego/key-value-view.vue';
-import FormInput from '@client/components/debobigego/input.vue';
-import FormButton from '@client/components/debobigego/button.vue';
-import FormBase from '@client/components/debobigego/base.vue';
-import FormGroup from '@client/components/debobigego/group.vue';
-import FormTextarea from '@client/components/debobigego/textarea.vue';
-import FormInfo from '@client/components/debobigego/info.vue';
-import FormSuspense from '@client/components/debobigego/suspense.vue';
 import MkInstanceStats from '@client/components/instance-stats.vue';
 import MkButton from '@client/components/ui/button.vue';
 import MkSelect from '@client/components/form/select.vue';
-import MkInput from '@client/components/form/input.vue';
+import MkNumberDiff from '@client/components/number-diff.vue';
 import MkContainer from '@client/components/ui/container.vue';
 import MkFolder from '@client/components/ui/folder.vue';
 import { version, url } from '@client/config';
@@ -68,12 +74,10 @@ import * as symbols from '@client/symbols';
 
 export default defineComponent({
 	components: {
-		FormBase,
-		FormSuspense,
-		FormGroup,
-		FormInfo,
+		MkNumberDiff,
 		FormKeyValueView,
 		MkInstanceStats,
+		MkContainer,
 		XMetrics,
 	},
 
@@ -82,17 +86,22 @@ export default defineComponent({
 	data() {
 		return {
 			[symbols.PAGE_INFO]: {
-				title: this.$ts.overview,
+				title: this.$ts.dashboard,
 				icon: 'fas fa-tachometer-alt',
 				bg: 'var(--bg)',
 			},
-			page: 'index',
+			header: {
+				title: this.$ts.dashboard,
+				icon: 'fas fa-tachometer-alt',
+			},
 			version,
+			vueVersion,
 			url,
 			stats: null,
 			meta: null,
-			fetchStats: () => os.api('stats', {}),
-			fetchServerInfo: () => os.api('admin/server-info', {}),
+			serverInfo: null,
+			usersComparedToThePrevDay: null,
+			notesComparedToThePrevDay: null,
 			fetchJobs: () => os.api('admin/queue/deliver-delayed', {}),
 			fetchModLogs: () => os.api('admin/show-moderation-logs', {}),
 		}
@@ -100,13 +109,29 @@ export default defineComponent({
 
 	async mounted() {
 		this.$emit('info', this[symbols.PAGE_INFO]);
+
+		os.api('meta', { detail: true }).then(meta => {
+			this.meta = meta;
+		});
+		
+		os.api('stats', {}).then(stats => {
+			this.stats = stats;
+
+			os.api('charts/users', { limit: 2, span: 'day' }).then(chart => {
+				this.usersComparedToThePrevDay = this.stats.originalUsersCount - chart.local.total[1];
+			});
+
+			os.api('charts/notes', { limit: 2, span: 'day' }).then(chart => {
+				this.notesComparedToThePrevDay = this.stats.originalNotesCount - chart.local.total[1];
+			});
+		});
+
+		os.api('admin/server-info', {}).then(serverInfo => {
+			this.serverInfo = serverInfo;
+		});
 	},
 
 	methods: {
-		async init() {
-			this.meta = await os.api('meta', { detail: true });
-		},
-	
 		async showInstanceInfo(q) {
 			let instance = q;
 			if (typeof q === 'string') {
@@ -125,3 +150,36 @@ export default defineComponent({
 	}
 });
 </script>
+
+<style lang="scss" scoped>
+.edbbcaef {
+	> .numbers {
+		display: grid;
+		grid-gap: 8px;
+		grid-template-columns: repeat(auto-fill,minmax(130px,1fr));
+		margin: 16px;
+
+		> .number {
+			padding: 12px 16px;
+
+			> .label {
+				opacity: 0.7;
+				font-size: 0.8em;
+			}
+
+			> .value {
+				font-weight: bold;
+				font-size: 1.2em;
+
+				> .diff {
+					font-size: 0.8em;
+				}
+			}
+		}
+	}
+
+	> .charts {
+		margin: var(--margin);
+	}
+}
+</style>
diff --git a/src/client/pages/instance/queue.chart.vue b/src/client/pages/instance/queue.chart.vue
index 887fe9a574..4f8fd762bb 100644
--- a/src/client/pages/instance/queue.chart.vue
+++ b/src/client/pages/instance/queue.chart.vue
@@ -67,7 +67,7 @@ export default defineComponent({
 		// TODO: var(--panel)の色が暗いか明るいかで判定する
 		const gridColor = this.$store.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
 
-		Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+		Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
 
 		this.chart = markRaw(new Chart(this.$refs.chart, {
 			type: 'line',
diff --git a/src/client/scripts/hpml/lib.ts b/src/client/scripts/hpml/lib.ts
index 150a04732f..200faf820b 100644
--- a/src/client/scripts/hpml/lib.ts
+++ b/src/client/scripts/hpml/lib.ts
@@ -1,11 +1,11 @@
 import * as tinycolor from 'tinycolor2';
-import Chart from 'chart.js';
 import { Hpml } from './evaluator';
 import { values, utils } from '@syuilo/aiscript';
 import { Fn, HpmlScope } from '.';
 import { Expr } from './expr';
 import * as seedrandom from 'seedrandom';
 
+/*
 // https://stackoverflow.com/questions/38493564/chart-area-background-color-chartjs
 Chart.pluginService.register({
 	beforeDraw: (chart, easing) => {
@@ -18,6 +18,7 @@ Chart.pluginService.register({
 		}
 	}
 });
+*/
 
 export function initAiLib(hpml: Hpml) {
 	return {
@@ -49,11 +50,12 @@ export function initAiLib(hpml: Hpml) {
 			]));
 		}),
 		'MkPages:chart': values.FN_NATIVE(([id, opts]) => {
+			/* TODO
 			utils.assertString(id);
 			utils.assertObject(opts);
 			const canvas = hpml.canvases[id.value];
 			const color = getComputedStyle(document.documentElement).getPropertyValue('--accent');
-			Chart.defaults.global.defaultFontColor = '#555';
+			Chart.defaults.color = '#555';
 			const chart = new Chart(canvas, {
 				type: opts.value.get('type').value,
 				data: {
@@ -122,6 +124,7 @@ export function initAiLib(hpml: Hpml) {
 					})
 				}
 			});
+			*/
 		})
 	};
 }
diff --git a/src/client/scripts/theme.ts b/src/client/scripts/theme.ts
index ad1b033edf..8b63821293 100644
--- a/src/client/scripts/theme.ts
+++ b/src/client/scripts/theme.ts
@@ -27,6 +27,7 @@ export const builtinThemes = [
 	require('@client/themes/d-astro.json5'),
 	require('@client/themes/d-future.json5'),
 	require('@client/themes/d-botanical.json5'),
+	require('@client/themes/d-pumpkin.json5'),
 	require('@client/themes/d-black.json5'),
 ] as Theme[];
 
diff --git a/src/client/themes/d-astro.json5 b/src/client/themes/d-astro.json5
index 2350e3d46d..c6a927ec3a 100644
--- a/src/client/themes/d-astro.json5
+++ b/src/client/themes/d-astro.json5
@@ -1,7 +1,7 @@
 {
 	id: '080a01c5-377d-4fbb-88cc-6bb5d04977ea',
 	base: 'dark',
-	name: 'Mi Astro',
+	name: 'Mi Astro Dark',
 	author: 'syuilo',
 	props: {
 		bg: '#232125',
diff --git a/src/client/themes/d-future.json5 b/src/client/themes/d-future.json5
index 1882609121..b6fa1ab0c1 100644
--- a/src/client/themes/d-future.json5
+++ b/src/client/themes/d-future.json5
@@ -1,7 +1,7 @@
 {
 	id: '32a637ef-b47a-4775-bb7b-bacbb823f865',
 
-	name: 'Mi Future',
+	name: 'Mi Future Dark',
 	author: 'syuilo',
 
 	base: 'dark',
diff --git a/src/client/themes/d-persimmon.json5 b/src/client/themes/d-persimmon.json5
index 11e9994f5e..e36265ff10 100644
--- a/src/client/themes/d-persimmon.json5
+++ b/src/client/themes/d-persimmon.json5
@@ -1,7 +1,7 @@
 {
 	id: 'c503d768-7c70-4db2-a4e6-08264304bc8d',
 
-	name: 'Mi Persimmon',
+	name: 'Mi Persimmon Dark',
 	author: 'syuilo',
 
 	base: 'dark',
diff --git a/src/client/themes/d-pumpkin.json5 b/src/client/themes/d-pumpkin.json5
new file mode 100644
index 0000000000..064ca4577b
--- /dev/null
+++ b/src/client/themes/d-pumpkin.json5
@@ -0,0 +1,88 @@
+{
+	id: '0b64fef3-02c7-20b5-dd87-b3f77e2b4301',
+
+	name: 'Mi Pumpkin Dark',
+	author: 'syuilo',
+
+	base: 'dark',
+
+	props: {
+		X2: ':darken<2<@panel',
+		X3: 'rgba(255, 255, 255, 0.05)',
+		X4: 'rgba(255, 255, 255, 0.1)',
+		X5: 'rgba(255, 255, 255, 0.05)',
+		X6: 'rgba(255, 255, 255, 0.15)',
+		X7: 'rgba(255, 255, 255, 0.05)',
+		X8: ':lighten<5<@accent',
+		X9: ':darken<5<@accent',
+		bg: 'rgb(37, 32, 47)',
+		fg: '#e0d5c0',
+		X10: ':alpha<0.4<@accent',
+		X11: 'rgba(0, 0, 0, 0.3)',
+		X12: 'rgba(255, 255, 255, 0.1)',
+		X13: 'rgba(255, 255, 255, 0.15)',
+		X14: ':alpha<0.5<@navBg',
+		X15: ':alpha<0<@panel',
+		X16: ':alpha<0.7<@panel',
+		X17: ':alpha<0.8<@bg',
+		cwBg: '#687390',
+		cwFg: '#393f4f',
+		link: 'rgb(172, 193, 68)',
+		warn: '#ecb637',
+		badge: '#31b1ce',
+		error: '#ec4137',
+		focus: ':alpha<0.3<@accent',
+		navBg: '@panel',
+		navFg: '@fg',
+		panel: ':lighten<3<@bg',
+		popup: ':lighten<3<@panel',
+		accent: 'rgb(242, 133, 36)',
+		header: ':alpha<0.7<@panel',
+		infoBg: '#253142',
+		infoFg: '#fff',
+		renote: 'rgb(110, 179, 72)',
+		shadow: 'rgba(0, 0, 0, 0.3)',
+		divider: 'rgba(255, 255, 255, 0.1)',
+		hashtag: 'rgb(188, 90, 255)',
+		mention: 'rgb(72, 179, 139)',
+		modalBg: 'rgba(0, 0, 0, 0.5)',
+		success: '#86b300',
+		buttonBg: 'rgba(255, 255, 255, 0.05)',
+		switchBg: 'rgba(255, 255, 255, 0.15)',
+		acrylicBg: ':alpha<0.5<@bg',
+		cwHoverBg: '#707b97',
+		indicator: '@accent',
+		mentionMe: '@accent',
+		messageBg: '@bg',
+		navActive: '@accent',
+		accentedBg: ':alpha<0.15<@accent',
+		fgOnAccent: '#000',
+		infoWarnBg: '#42321c',
+		infoWarnFg: '#ffbd3e',
+		navHoverFg: ':lighten<17<@fg',
+		dateLabelFg: '@fg',
+		inputBorder: 'rgba(255, 255, 255, 0.1)',
+		panelBorder: '" solid 1px var(--divider)',
+		accentDarken: ':darken<10<@accent',
+		acrylicPanel: ':alpha<0.5<@panel',
+		navIndicator: '@indicator',
+		accentLighten: ':lighten<10<@accent',
+		buttonHoverBg: 'rgba(255, 255, 255, 0.1)',
+		driveFolderBg: ':alpha<0.3<@accent',
+		fgHighlighted: ':lighten<3<@fg',
+		fgTransparent: ':alpha<0.5<@fg',
+		panelHeaderBg: ':lighten<3<@panel',
+		panelHeaderFg: '@fg',
+		buttonGradateA: '@accent',
+		buttonGradateB: ':hue<20<@accent',
+		htmlThemeColor: '@bg',
+		panelHighlight: ':lighten<3<@panel',
+		listItemHoverBg: 'rgba(255, 255, 255, 0.03)',
+		scrollbarHandle: 'rgba(255, 255, 255, 0.2)',
+		inputBorderHover: 'rgba(255, 255, 255, 0.2)',
+		wallpaperOverlay: 'rgba(0, 0, 0, 0.5)',
+		fgTransparentWeak: ':alpha<0.75<@fg',
+		panelHeaderDivider: 'rgba(0, 0, 0, 0)',
+		scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)',
+	},
+}
diff --git a/src/client/themes/l-apricot.json5 b/src/client/themes/l-apricot.json5
index 74cb24d407..1ed5525575 100644
--- a/src/client/themes/l-apricot.json5
+++ b/src/client/themes/l-apricot.json5
@@ -1,7 +1,7 @@
 {
 	id: '0ff48d43-aab3-46e7-ab12-8492110d2e2b',
 
-	name: 'Mi Apricot',
+	name: 'Mi Apricot Light',
 	author: 'syuilo',
 
 	base: 'light',
diff --git a/src/client/themes/l-rainy.json5 b/src/client/themes/l-rainy.json5
index 1edde1cabf..283dd74c6c 100644
--- a/src/client/themes/l-rainy.json5
+++ b/src/client/themes/l-rainy.json5
@@ -1,7 +1,7 @@
 {
 	id: 'a58a0abb-ff8c-476a-8dec-0ad7837e7e96',
 
-	name: 'Mi Rainy',
+	name: 'Mi Rainy Light',
 	author: 'syuilo',
 
 	base: 'light',
diff --git a/src/client/themes/l-vivid.json5 b/src/client/themes/l-vivid.json5
index 0f4abe0a45..b3c08f38ae 100644
--- a/src/client/themes/l-vivid.json5
+++ b/src/client/themes/l-vivid.json5
@@ -1,7 +1,7 @@
 {
 	id: '6128c2a9-5c54-43fe-a47d-17942356470b',
 
-	name: 'Mi Vivid',
+	name: 'Mi Vivid Light',
 	author: 'syuilo',
 
 	base: 'light',
diff --git a/src/queue/index.ts b/src/queue/index.ts
index 43c062bae7..37eb809604 100644
--- a/src/queue/index.ts
+++ b/src/queue/index.ts
@@ -10,7 +10,7 @@ import procesObjectStorage from './processors/object-storage/index';
 import { queueLogger } from './logger';
 import { DriveFile } from '@/models/entities/drive-file';
 import { getJobInfo } from './get-job-info';
-import { dbQueue, deliverQueue, inboxQueue, objectStorageQueue } from './queues';
+import { systemQueue, dbQueue, deliverQueue, inboxQueue, objectStorageQueue } from './queues';
 import { ThinUser } from './types';
 import { IActivity } from '@/remote/activitypub/type';
 
@@ -22,11 +22,20 @@ function renderError(e: Error): any {
 	};
 }
 
+const systemLogger = queueLogger.createSubLogger('system');
 const deliverLogger = queueLogger.createSubLogger('deliver');
 const inboxLogger = queueLogger.createSubLogger('inbox');
 const dbLogger = queueLogger.createSubLogger('db');
 const objectStorageLogger = queueLogger.createSubLogger('objectStorage');
 
+systemQueue
+	.on('waiting', (jobId) => systemLogger.debug(`waiting id=${jobId}`))
+	.on('active', (job) => systemLogger.debug(`active id=${job.id}`))
+	.on('completed', (job, result) => systemLogger.debug(`completed(${result}) id=${job.id}`))
+	.on('failed', (job, err) => systemLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) }))
+	.on('error', (job: any, err: Error) => systemLogger.error(`error ${err}`, { job, e: renderError(err) }))
+	.on('stalled', (job) => systemLogger.warn(`stalled id=${job.id}`));
+
 deliverQueue
 	.on('waiting', (jobId) => deliverLogger.debug(`waiting id=${jobId}`))
 	.on('active', (job) => deliverLogger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`))
@@ -220,12 +229,17 @@ export function createCleanRemoteFilesJob() {
 }
 
 export default function() {
-	if (!envOption.onlyServer) {
-		deliverQueue.process(config.deliverJobConcurrency || 128, processDeliver);
-		inboxQueue.process(config.inboxJobConcurrency || 16, processInbox);
-		processDb(dbQueue);
-		procesObjectStorage(objectStorageQueue);
-	}
+	if (envOption.onlyServer) return;
+
+	deliverQueue.process(config.deliverJobConcurrency || 128, processDeliver);
+	inboxQueue.process(config.inboxJobConcurrency || 16, processInbox);
+	processDb(dbQueue);
+	procesObjectStorage(objectStorageQueue);
+
+	systemQueue.add('resyncCharts', {
+	}, {
+		repeat: { cron: '0 0 * * *' }
+	});
 }
 
 export function destroy() {
diff --git a/src/queue/processors/system/index.ts b/src/queue/processors/system/index.ts
new file mode 100644
index 0000000000..52b7868105
--- /dev/null
+++ b/src/queue/processors/system/index.ts
@@ -0,0 +1,12 @@
+import * as Bull from 'bull';
+import { resyncCharts } from './resync-charts';
+
+const jobs = {
+	resyncCharts,
+} as Record<string, Bull.ProcessCallbackFunction<{}> | Bull.ProcessPromiseFunction<{}>>;
+
+export default function(dbQueue: Bull.Queue<{}>) {
+	for (const [k, v] of Object.entries(jobs)) {
+		dbQueue.process(k, v);
+	}
+}
diff --git a/src/queue/processors/system/resync-charts.ts b/src/queue/processors/system/resync-charts.ts
new file mode 100644
index 0000000000..b36b024cfb
--- /dev/null
+++ b/src/queue/processors/system/resync-charts.ts
@@ -0,0 +1,21 @@
+import * as Bull from 'bull';
+
+import { queueLogger } from '../../logger';
+import { driveChart, notesChart, usersChart } from '@/services/chart/index';
+
+const logger = queueLogger.createSubLogger('resync-charts');
+
+export default async function resyncCharts(job: Bull.Job<{}>, done: any): Promise<void> {
+	logger.info(`Resync charts...`);
+
+	// TODO: ユーザーごとのチャートも更新する
+	// TODO: インスタンスごとのチャートも更新する
+	await Promise.all([
+		driveChart.resync(),
+		notesChart.resync(),
+		usersChart.resync(),
+	]);
+
+	logger.succ(`All charts successfully resynced.`);
+	done();
+}
diff --git a/src/queue/queues.ts b/src/queue/queues.ts
index d8c09ef86e..a66a7ca451 100644
--- a/src/queue/queues.ts
+++ b/src/queue/queues.ts
@@ -2,6 +2,7 @@ import config from '@/config/index';
 import { initialize as initializeQueue } from './initialize';
 import { DeliverJobData, InboxJobData, DbJobData, ObjectStorageJobData } from './types';
 
+export const systemQueue = initializeQueue<{}>('system');
 export const deliverQueue = initializeQueue<DeliverJobData>('deliver', config.deliverJobPerSec || 128);
 export const inboxQueue = initializeQueue<InboxJobData>('inbox', config.inboxJobPerSec || 16);
 export const dbQueue = initializeQueue<DbJobData>('db');
diff --git a/src/server/api/endpoints/admin/resync-chart.ts b/src/server/api/endpoints/admin/resync-chart.ts
index b0e687333f..e01dfce1b6 100644
--- a/src/server/api/endpoints/admin/resync-chart.ts
+++ b/src/server/api/endpoints/admin/resync-chart.ts
@@ -1,5 +1,5 @@
 import define from '../../define';
-import { driveChart, notesChart, usersChart, instanceChart } from '@/services/chart/index';
+import { driveChart, notesChart, usersChart } from '@/services/chart/index';
 import { insertModerationLog } from '@/services/insert-moderation-log';
 
 export const meta = {
@@ -15,7 +15,7 @@ export default define(meta, async (ps, me) => {
 	driveChart.resync();
 	notesChart.resync();
 	usersChart.resync();
-	instanceChart.resync();
 
 	// TODO: ユーザーごとのチャートもキューに入れて更新する
+	// TODO: インスタンスごとのチャートもキューに入れて更新する
 });
diff --git a/src/server/web/manifest.json b/src/server/web/manifest.json
index db97531bbf..48030a2980 100644
--- a/src/server/web/manifest.json
+++ b/src/server/web/manifest.json
@@ -2,7 +2,7 @@
 	"short_name": "Misskey",
 	"name": "Misskey",
 	"start_url": "/",
-	"display": "minimal-ui",
+	"display": "standalone",
 	"background_color": "#313a42",
 	"theme_color": "#86b300",
 	"icons": [
diff --git a/yarn.lock b/yarn.lock
index e2140e185a..449390a6a2 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2664,28 +2664,22 @@ character-parser@^2.2.0:
   dependencies:
     is-regex "^1.0.3"
 
-chart.js@2.9.4:
-  version "2.9.4"
-  resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-2.9.4.tgz#0827f9563faffb2dc5c06562f8eb10337d5b9684"
-  integrity sha512-B07aAzxcrikjAPyV+01j7BmOpxtQETxTSlQ26BEYJ+3iUkbNKaOJ/nDbT6JjyqYxseM0ON12COHYdU2cTIjC7A==
-  dependencies:
-    chartjs-color "^2.1.0"
-    moment "^2.10.2"
+chart.js@3.5.1:
+  version "3.5.1"
+  resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-3.5.1.tgz#73e24d23a4134a70ccdb5e79a917f156b6f3644a"
+  integrity sha512-m5kzt72I1WQ9LILwQC4syla/LD/N413RYv2Dx2nnTkRS9iv/ey1xLTt0DnPc/eWV4zI+BgEgDYBIzbQhZHc/PQ==
 
-chartjs-color-string@^0.6.0:
-  version "0.6.0"
-  resolved "https://registry.yarnpkg.com/chartjs-color-string/-/chartjs-color-string-0.6.0.tgz#1df096621c0e70720a64f4135ea171d051402f71"
-  integrity sha512-TIB5OKn1hPJvO7JcteW4WY/63v6KwEdt6udfnDE9iCAZgy+V4SrbSxoIbTw/xkUIapjEI4ExGtD0+6D3KyFd7A==
-  dependencies:
-    color-name "^1.0.0"
+chartjs-adapter-date-fns@2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-2.0.0.tgz#5e53b2f660b993698f936f509c86dddf9ed44c6b"
+  integrity sha512-rmZINGLe+9IiiEB0kb57vH3UugAtYw33anRiw5kS2Tu87agpetDDoouquycWc9pRsKtQo5j+vLsYHyr8etAvFw==
 
-chartjs-color@^2.1.0:
-  version "2.4.1"
-  resolved "https://registry.yarnpkg.com/chartjs-color/-/chartjs-color-2.4.1.tgz#6118bba202fe1ea79dd7f7c0f9da93467296c3b0"
-  integrity sha512-haqOg1+Yebys/Ts/9bLo/BqUcONQOdr/hoEr2LLTRl6C5LXctUdHxsCYfvQVg5JIxITrfCNUDr4ntqmQk9+/0w==
+chartjs-plugin-zoom@1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/chartjs-plugin-zoom/-/chartjs-plugin-zoom-1.1.1.tgz#8a28923a17fcb5eb57a0dc94c5113bf402677647"
+  integrity sha512-1q54WOzK7FtAjkbemQeqvmFUV0btNYIQny2HbQ6Awq9wUtCz7Zmj6vIgp3C1DYMQwN0nqgpC3vnApqiwI7cSdQ==
   dependencies:
-    chartjs-color-string "^0.6.0"
-    color-convert "^1.9.3"
+    hammerjs "^2.0.8"
 
 check-more-types@2.24.0, check-more-types@^2.24.0:
   version "2.24.0"
@@ -2974,7 +2968,7 @@ collection-visit@^1.0.0:
     map-visit "^1.0.0"
     object-visit "^1.0.0"
 
-color-convert@^1.3.0, color-convert@^1.9.0, color-convert@^1.9.3:
+color-convert@^1.3.0, color-convert@^1.9.0:
   version "1.9.3"
   resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
   integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==
@@ -3616,6 +3610,11 @@ data-urls@^2.0.0:
     whatwg-mimetype "^2.3.0"
     whatwg-url "^8.0.0"
 
+date-fns@2.25.0:
+  version "2.25.0"
+  resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.25.0.tgz#8c5c8f1d958be3809a9a03f4b742eba894fc5680"
+  integrity sha512-ovYRFnTrbGPD4nqaEqescPEv1mNwvt+UTqI3Ay9SzNtey9NZnYu6E2qCcBBgJ6/2VF1zGGygpyTDITqpQQ5e+w==
+
 date-fns@^2.16.1:
   version "2.19.0"
   resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.19.0.tgz#65193348635a28d5d916c43ec7ce6fbd145059e1"
@@ -5300,6 +5299,11 @@ gulplog@^1.0.0:
   dependencies:
     glogg "^1.0.0"
 
+hammerjs@^2.0.8:
+  version "2.0.8"
+  resolved "https://registry.yarnpkg.com/hammerjs/-/hammerjs-2.0.8.tgz#04ef77862cff2bb79d30f7692095930222bf60f1"
+  integrity sha1-BO93hiz/K7edMPdpIJWTAiK/YPE=
+
 har-schema@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92"
@@ -7383,7 +7387,7 @@ moment-timezone@^0.5.25:
   dependencies:
     moment ">= 2.9.0"
 
-"moment@>= 2.9.0", moment@^2.10.2, moment@^2.22.2:
+"moment@>= 2.9.0", moment@^2.22.2:
   version "2.24.0"
   resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b"
   integrity sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==