0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【#4】承認・履歴付 OSS SSH MCP server ssh-gate -- Wails GUI 編

0
Last updated at Posted at 2026-07-17

Electron でも Tauri でもなく Wails — Go + バニラ JS 1ファイルで管理画面を作る

連載「AI エージェントに SSH を渡すのが怖いので、人間承認ゲートウェイを自作した」第4回(Wails GUI 編)。

  • リポジトリ: ssh-getessh-gate-oss) —
  • 対象バージョン: main(タグ未発行。本記事のコード引用は記事公開時点の main を参照)
  • この回は単体でも読めます。連載の入口は第1回(コンセプト編)です。


0. この記事のゴール

ssh-gete は「AI エージェントが要求した SSH コマンドを、人間が承認してから実行する」デスクトップアプリです。バックエンドは Go、フロントは React なし・ビルドツール最小・frontend/src/main.js の1ファイル(約1,465行) で書かれています。

それでも、この1ファイルで以下の4画面が成立します。

  • 接続先管理(SSH / シリアル接続先の登録・編集・疎通テスト)
  • コマンド履歴・承認(MCP から来た要求の承認キューと実行履歴)
  • ターミナル(xterm.js による SSH 対話シェル/シリアルコンソール)
  • MCP 待受設定(MCP サーバーのポート・認証・承認ポリシー)

この記事で扱うのは「なぜ Wails か」「なぜ仮想 DOM なしの全画面再描画で成立するのか」、そして「全画面再描画とライブターミナルをどう両立させたか」です。最後の点が一番のハマりどころで、ここに一番紙幅を割きます。


1. Wails とは — Go の公開メソッドが JS からそのまま呼べる

Wails は Go + Web フロントエンドでデスクトップアプリを作るフレームワークです。最大の特徴は、Go の構造体メソッドが JavaScript の async 関数としてそのまま呼べること。バインディングコードは Wails が自動生成します。

エントリーポイントは拍子抜けするほど短い main.go(全36行)です。

// main.go:14
func main() {
    app := NewApp()

    err := wails.Run(&options.App{
        Title:     "ssh-gete",
        Width:     1440,
        Height:    960,
        MinWidth:  1180,
        MinHeight: 760,
        AssetServer: &assetserver.Options{
            Assets: assets,             // //go:embed all:frontend/dist
        },
        BackgroundColour: &options.RGBA{R: 238, G: 243, B: 249, A: 1},
        OnStartup:        app.startup,
        OnShutdown:       app.shutdown,
        Bind: []interface{}{
            app,                         // ← この app の公開メソッドが JS へ露出する
        },
    })
    // ...
}

ポイントは Bind: []interface{}{app}main.go:29)です。これだけで App 型の公開メソッドがすべて JS 側に生える。フロント側はこう import します。

// frontend/src/main.js:10
import {
  ApproveCommand,
  ApproveCommandAndBypassAgent,
  CloseTerminal,
  DeleteConnection,
  // ...
  GetInitialData,
  ListSerialPorts,
  SaveConnection,
  SaveMCPSettings,
  SendTerminalInput,
  StartTerminal,
  TestConnectionConfig,
} from '../wailsjs/go/main/App';
import { EventsOn } from '../wailsjs/runtime/runtime';

../wailsjs/go/main/App は Wails が生成するファイルで、各 Go メソッドの Promise ラッパが入っています。たとえば GetInitialData()await すると、Go の App.GetInitialData() が返す DashboardData(JSON)がそのまま JS のオブジェクトとして手に入ります。REST API も IPC のメッセージ定義も書きません。これが Wails の効きどころです。

main.go:11//go:embed all:frontend/dist でフロントのビルド成果物をバイナリに埋め込むので、配布物は単一の実行ファイルになります(ローカルサーバーも別プロセスもなし)。


2. Electron / Tauri との比較 — なぜ Wails を選んだか

Electron Tauri Wails
バックエンド言語 Node.js Rust Go
レンダラ Chromium 同梱 OS の WebView OS の WebView
バイナリサイズ 大(数十〜100MB超)
メモリ 重い 軽い 軽い
Go 資産との相性

ssh-gete のバックエンドは、SSH 実行(golang.org/x/crypto/ssh)・SQLite・MCP サーバー(net/http)・シリアル(go.bug.st/serial)とほぼ Go エコシステムで完結しています。これを別言語のフロントから IPC で叩くのは無駄が多い。Wails なら 承認ロジックや SSH 実行をそのまま Go で書き、UI だけ Web 技術で被せるのが自然です。

Electron は Chromium を丸ごと同梱するためサイズ・メモリが重く、ローカル管理ツールには過剰でした。Tauri は軽量で魅力的ですが、バックエンドが Rust になり Go 資産を活かせません。「Go で書いたバックエンドを最小コストで GUI 化する」という一点で Wails が最適解でした。


3. AdminLTE / Bootstrap で「管理画面の見た目」を最速調達する

フロントの見た目はゼロから作っていません。冒頭の import で AdminLTE(Bootstrap ベースの管理画面テンプレート) を丸ごと読み込みます。

// frontend/src/main.js:1
import 'admin-lte/dist/css/adminlte.min.css';
import 'admin-lte/dist/js/adminlte.min.js';
import 'bootstrap-icons/font/bootstrap-icons.css';
import '@xterm/xterm/css/xterm.css';
import './style.css';

document.body.classNamemain.js:30)に AdminLTE のレイアウトクラスを当てるだけで、サイドバー・ヘッダー・カード・バッジ・テーブルといった「管理画面の部品」が全部使えます。あとは cardlist-groupbadge text-bg-success といったクラス名を文字列で書くだけ。CSS をほとんど自分で書かずに「それっぽい管理画面」が立ち上がります

個人開発でいちばん時間を食う「デザインの帳尻合わせ」を、既製テンプレートに丸投げするのは現実的な判断です。


4. 全画面再描画モデル — 仮想 DOM なしで成立する条件

フロントの描画方針は驚くほど素朴です。

状態(state)から HTML 文字列を組み立て、app.innerHTML に丸ごと代入する。

React も Vue も仮想 DOM もありません。状態が変わったら、画面全体を作り直して差し替えるだけです。

// frontend/src/main.js:1122
function render() {
  const content = state.active === 'connections'
    ? renderConnections()
    : state.active === 'commands'
      ? renderCommands()
      : state.active === 'terminal'
        ? renderTerminal()
        : renderSettings();

  app.innerHTML = shell(content);   // ← サイドバー・ヘッダー・モーダルごと全置換

  if (state.active === 'terminal') {
    mountTerminal();                // ← ここが伏線(後述)
  }
}

状態は単一のプレーンオブジェクト statemain.js:34)に集約します。

const state = {
  active: 'connections',          // 現在のタブ
  selectedConnection: 0,
  selectedRequest: 0,
  data: { connections: [], requests: [], agentPolicies: [], mcp: {} },
  dirty: { connections: false, settings: false },   // フォーム編集中フラグ
  passphrase: { /* モーダル状態 */ },
  // ...
  terminal: { connected: false, label: '' },
  serialPorts: [],
  toast: '',
};

描画関数(renderConnectionsconnectionRowhistoryTableinputCol など)はすべて**「state を受けてテンプレートリテラル文字列を返す純関数」**です。状態が決まれば画面が一意に決まる、という点では React と発想は同じで、差分計算をサボっているだけです。

なぜそれで成立するのか

全画面 innerHTML 全置換は、普通は「重い」「フォーカスが飛ぶ」「DOM 状態が消える」と嫌われます。それでも ssh-gete で成立しているのは、次の条件が揃っているからです。

  • ローカル単一ユーザーのデスクトップアプリ:描画対象は接続先数件・履歴数十件規模。フル再構築のコストが体感に出ない。
  • イベント委譲#app に対するクリック/サブミット/入力のリスナを1度だけ張る(main.js:1138 以降)。innerHTML を入れ替えても再アタッチ不要。要素ごとの data-action 属性で分岐します。
  • 状態が JS 側に一元化:DOM は「状態の写像」でしかなく、DOM に状態を溜めない。

ただし、この素朴さがそのままでは破綻する箇所が2つあります。「フォーム入力中の上書き」と「ライブターミナルの破棄」です。順に潰していきます。


5. 編集保護つきポーリング — 再描画とフォーム入力の衝突を防ぐ

MCP の承認待ちは「いつ来るか分からない」ので、フロントは末尾でポーリングを仕掛けています。

// frontend/src/main.js:1464
refreshData();
window.setInterval(() => refreshData({ silent: true }), 5000);

5秒ごとに GetInitialData() を叩いて state.data を更新します。ところが、ユーザーが接続先フォームを編集している最中に再描画が走ると、入力が消えます(全置換なので当然)。

これを防ぐのが dirty フラグと、refreshData 内の編集保護ロジックです。

// frontend/src/main.js:176
async function refreshData({ silent = false } = {}) {
  try {
    const next = await GetInitialData();
    const protectConnections = state.active === 'connections' && state.dirty.connections;
    const protectSettings = state.active === 'settings' && state.dirty.settings;
    const protectModal = state.passphrase.open || state.deleteConnection.open || state.deleteCommand.open;
    // The live xterm must not be torn down by the periodic poll.
    const protectTerminal = state.active === 'terminal';
    state.data = {
      connections: protectConnections ? state.data.connections : next.connections,
      requests: next.requests,
      agentPolicies: next.agentPolicies ?? [],
      mcp: protectSettings ? state.data.mcp : next.mcp,
    };
    // ...
    if (!protectConnections && !protectSettings && !protectModal && !protectTerminal) render();
  } catch (error) { /* ... */ }
}

設計のキモは2段構えです。

  1. データの上書きを止める:編集中(dirty.connections / dirty.settings)のセクションは state.data を更新しない。一方で承認キュー(requests)は編集対象ではないので常に最新化する。承認待ちの通知が遅れたら本末転倒だからです。
  2. 再描画そのものを止める:編集中・モーダル表示中・ターミナル表示中は render() を呼ばない(state.data だけ静かに更新)。

dirty フラグは、フォームに触れた瞬間に立てます。

// frontend/src/main.js:1430
app.addEventListener('input', (event) => {
  // ...
  if (event.target.closest('#connection-form')) state.dirty.connections = true;
  if (event.target.closest('#mcp-form')) state.dirty.settings = true;
});

そして保存・接続先切替・削除のタイミングで dirty を畳みます(例:main.js:1389main.js:1403)。「全画面再描画モデルを採るなら、ポーリングとフォーム入力の衝突は必ず設計する」——これが仮想 DOM なしで実用に耐えるための1つ目の条件です。


6. XSS 対策とフロント側の安全固定 — バックエンドとの二重化

6.1 escapeHtml で文字列を必ず無害化する

innerHTML に文字列を流し込む方式は、そのままだと XSS の温床です。接続先名・コマンド・理由など、ユーザー(やエージェント)由来の値を生でテンプレートに埋めれば、<script> が走ります。

そこで、テンプレートに値を入れる箇所では必ず escapeHtmlmain.js:90)を通します。

// frontend/src/main.js:90
function escapeHtml(value) {
  return String(value ?? '')
    .replaceAll('&', '&amp;')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;')
    .replaceAll('"', '&quot;')
    .replaceAll("'", '&#039;');
}

履歴テーブルのコマンド表示や接続先名など、外部由来の値はすべて ${escapeHtml(...)} で囲んでいます(例:historyTablemain.js:1023connectionRowmain.js:431)。全画面 innerHTML 方式を採る以上、**escapeHtml の貫徹は「やった方がいい」ではなく「必須」**です。

6.2 危険な機能はフロントでも false に固定する

もう一つ、地味だが重要な防御があります。MCP 設定を収集する collectMCPFormmain.js:1089)では、自動実行フラグを常に false でハードコードしています。

// frontend/src/main.js:1114
return {
  enabled: checked('enabled'),
  // ...
  strictHostKey: checked('strictHostKey'),
  autoExecuteLowRisk: false,        // ← フォームに無くても、常に false で送る
  requireApprovalSudo: checked('requireApprovalSudo'),
  // ...
};

autoExecuteLowRisk(低リスクコマンドの自動実行)は、第3回で触れたとおりバックエンドに実装はあるものの、承認ゲートウェイの「デフォルト安全」原則に反するため UI から無効化しています。そもそも設定画面にチェックボックスを置かず、フォーム収集時に問答無用で false を送る

ここで効くのが「安全側の固定はフロントとバックの二重で」という考え方です。バックエンド側でもサニタイズしていますが、フロントでも false に倒しておく。どちらか一方が緩んでも、もう一方が止める。ローカルアプリでも、危険なスイッチは「押せないようにする」のが堅実です。


7. パスフレーズの UI フロー — DB に置かない値をどう入力・保持するか

ssh-gete のポリシーとして、SSH 鍵のパスフレーズは SQLite に保存しません(メモリのみ、アプリ起動中だけ)。トークンは DB、パスフレーズはメモリ、という線引きです。では、保存しない値をどうやって入力させるのか。

フローはこうです。

  1. ユーザーが接続先編集で「接続」を押す → TestConnectionConfig(connection, '') をパスフレーズ空で実行。
  2. 鍵がパスフレーズ保護されていれば、バックエンドが passphrase protected を含むエラーを返す。
  3. フロントはそれを検知してパスフレーズ入力モーダルを開く。
// frontend/src/main.js:1214
if (action === 'connect-ssh') {
  const connectionToTest = collectConnectionForm();
  const result = await TestConnectionConfig(connectionToTest, '');
  if (!result.ok && result.message.includes('passphrase protected')) {
    state.passphrase = {
      open: true,
      connectionName: connectionToTest.name || connectionToTest.host || '未保存の接続先',
      value: '',
      connection: connectionToTest,    // ← 入力中の接続先設定を保持して再試行に使う
    };
    render();
    return;
  }
  // ...
}

モーダルのフォームには「SQLite には保存せず、このアプリ起動中の SSH 実行だけに使います」と明記してあります(passphraseModalmain.js:406)。入力値は state.passphrase.value に持ち、サブミットで TestConnectionConfig(connection, passphrase) を再実行します。

// frontend/src/main.js:1415
if (event.target.id === 'passphrase-form') {
  const data = Object.fromEntries(new FormData(event.target));
  const connectionToTest = state.passphrase.connection || collectConnectionForm();
  const result = await TestConnectionConfig(connectionToTest, field(data, 'passphrase', state.passphrase.value));
  // ...
  state.passphrase = { open: false, connectionName: '', value: '', connection: null };
  // ...
}

成功すればバックエンドがそのプロセスのメモリにだけパスフレーズを覚え、以降の SSH 実行で使います。フロントは送り終えたら state.passphrase をクリアして手放す。「DB に置かない値」は、入力モーダル → 即バックエンドへ受け渡し → フロントは保持しない、という一方通行で扱うのが安全です。


8. ライブターミナルとの両立 — 全画面再描画の中で xterm.js を破棄させない

ここが本記事の核心です。第6回で作るシリアルコンソール/SSH 対話シェルは、xterm.js のライブ端末です。これを全画面 innerHTML 再描画モデルに素直に置くと、5秒ポーリングや他操作の再描画のたびに端末 DOM が破棄され、対話セッションが壊れます

たとえば vim を開いている最中に再描画が走れば、端末の DOM ごと作り直されてセッションが切れる——これでは使い物になりません。解決は2段構えです。

8.1 xterm インスタンスを再描画の外に保持し、DOM を「移植」する

まず、xterm のインスタンスを state でも DOM でもなくモジュールスコープの変数に持ちます。

// frontend/src/main.js:79
// xterm lives outside the render() innerHTML cycle: the instance is kept here and
// its DOM element is re-attached after each render so the live session survives.
let term = null;
let fitAddon = null;
let termWired = false;

render() の末尾で state.active === 'terminal' のときだけ mountTerminal() を呼びます(main.js:1133)。その中身がポイントです。

// frontend/src/main.js:738
function mountTerminal() {
  const host = document.querySelector('#xterm-host');
  if (!host) return;
  ensureTerminal();
  if (!term.element) {
    term.open(host);                          // 初回だけ生成
  } else if (term.element.parentElement !== host) {
    host.appendChild(term.element);           // 2回目以降は“移植”
  }
  if (!termWired) {
    term.onData((data) => {                   // キー入力を送る配線は1度だけ
      if (state.terminal.connected) SendTerminalInput(data);
    });
    termWired = true;
  }
  fitTerminal();
}

肝は「再生成ではなく appendChild による移植」です。render()#app の中身を全置換すると、古い #xterm-host ごと xterm の DOM 要素も親から外れます。しかし term.element 自体は破棄されず、モジュール変数 term が握ったまま。だから、新しく描かれた #xterm-hostappendChild付け替えるだけで、スクロールバックも入力中の状態も生きたまま端末が復活します。

キー入力の配線(term.onData)も termWired フラグで1度しか張りません。再アタッチのたびに配線すると、入力が多重送信されてしまうからです。

8.2 ターミナル表示中はポーリング再描画そのものを止める

移植だけでも端末は生き残りますが、もっと確実なのは「そもそも余計な render() を起こさない」ことです。これが第5節で見た protectTerminal の正体です。

// frontend/src/main.js:183
// The live xterm must not be torn down by the periodic poll.
const protectTerminal = state.active === 'terminal';
// ...
if (!protectConnections && !protectSettings && !protectModal && !protectTerminal) render();

ターミナルタブを開いている間、5秒ポーリングは state.data を裏で更新するだけで render() を呼びません。これがないと、端末操作中に勝手に画面が再構築されてしまいます。「移植で生き残らせる」+「そもそも再描画しない」の二重防御です。

8.3 受信は Wails イベント、入力は Go メソッド呼び出し

端末出力は、ポーリングではなく Wails のイベントでフロントへ push します。バイナリ安全のため base64 で運び、フロントで復号して term.write します。

// frontend/src/main.js:788
EventsOn('terminal:data', (payload) => {
  if (term) term.write(b64ToBytes(payload));
});

EventsOn('terminal:exit', (message) => {
  if (term) term.write(`\r\n\x1b[33m[${message}]\x1b[0m\r\n`);
  state.terminal.connected = false;
  state.terminal.label = '';
  if (state.active === 'terminal') render();
});

// frontend/src/main.js:718
function b64ToBytes(b64) {
  const bin = atob(b64);
  const bytes = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i += 1) bytes[i] = bin.charCodeAt(i);
  return bytes;
}

逆向き(キー入力)は SendTerminalInput(data) という Go メソッド呼び出し、ウィンドウリサイズは fitAddon.fit()ResizeTerminal(cols, rows) で追従します(fitTerminalmain.js:756)。

// frontend/src/main.js:799
window.addEventListener('resize', () => {
  if (state.active === 'terminal') fitTerminal();
});

8.4 後始末 — タブ離脱・切断で必ず破棄する

タブを離れる、または「切断」を押したら、teardownTerminal() でバックエンドのセッションを閉じ、xterm インスタンスも破棄して、次回はクリーンに作り直せるようにします。

// frontend/src/main.js:774
async function teardownTerminal() {
  if (state.terminal.connected) {
    await CloseTerminal();
  }
  state.terminal.connected = false;
  state.terminal.label = '';
  if (term) {
    term.dispose();
    term = null;
    fitAddon = null;
    termWired = false;            // 配線フラグも戻す(次回は1から張り直す)
  }
}

タブ切替時にこれを呼ぶのはナビゲーション処理の中です。

// frontend/src/main.js:1139
const nav = event.target.closest('[data-nav]');
if (nav) {
  const target = nav.dataset.nav;
  if (state.active === 'terminal' && target !== 'terminal') {
    await teardownTerminal();    // ターミナルから他タブへ移るとき必ず破棄
  }
  state.active = target;
  render();
  return;
}

設計のまとめ(ターミナル):xterm のインスタンスを再描画サイクルの外に逃がす(モジュール変数)→ 再描画後は DOM を移植(再生成しない)→ ターミナル中はポーリング再描画を抑止 → 受信はイベント、送信は Go 呼び出し → 離脱時は確実に破棄。この5点が揃って初めて、「全画面再描画モデル」と「ライブ端末」が同居します。

なお、SSH 対話シェルとシリアルコンソールはフロントから見ると完全に同じ受け口です。違いは「選んだ接続先の種別(type)」だけで、StartTerminal(name) を呼べばバックエンドが自動でディスパッチします(トランスポート抽象化の詳細は第6回で扱います)。現状のターミナル画面は接続先の種別に従属し、renderTerminalmain.js:635)が見出しを「インタラクティブシェル」「シリアルコンソール」に出し分けます。


9. 4画面の構成(参考)

現状の画面は4つで、サイドバーの navItemmain.js:241)で切り替えます。

  • 接続先管理(renderConnectionsmain.js:315:左に接続先一覧(SSH/Serial バッジ付き)、右に編集フォーム。接続種類セレクタで SSH / Serial フォームが切り替わる。
  • コマンド履歴・承認(renderCommandsmain.js:513:承認キュー+選択要求の詳細(コマンド・理由・stdout/stderr)+全履歴テーブル。「承認して実行」「拒否」「エージェントは承認省略」。
  • ターミナル(renderTerminalmain.js:635:左に接続先選択+詳細、右に xterm.js のライブ端末。
  • MCP 待受設定(renderSettingsmain.js:803:左に全設定フォーム、右に公開エンドポイントとエージェント用設定 JSON(agentConfig)。

いずれも「state を受けて文字列を返す純関数」で、render() が1本で束ねます。画面追加は「描画関数を1つ足して render() の分岐に加える」だけ。脱フレームワークでも、状態を一元化して描画を純関数にしておけば、見通しは保てます。


まとめ — ローカル管理画面ならフレームワーク不要は現実解

  • Wails は「Go の公開メソッドが JS からそのまま呼べる」Bind: []interface{}{app} だけで API 定義も IPC も書かずに済み、Go 資産をそのまま GUI 化できる。バイナリは単一実行ファイル(//go:embed)。
  • Electron(重い)でも Tauri(Rust)でもなく Wails を選んだのは、バックエンドが Go エコシステムで完結しているから。
  • AdminLTE/Bootstrap で管理画面の見た目を最速調達。CSS をほぼ書かない。
  • 全画面 innerHTML 再描画+イベント委譲という素朴なモデルでも、ローカル単一ユーザー・状態一元化・純関数描画という条件が揃えば実用に耐える。React は要らなかった。
  • ただし素朴さの代償は2つ。編集保護つきポーリングdirty フラグ+protect*)でフォーム入力との衝突を防ぎ、escapeHtml の貫徹で XSS を防ぐ。危険なスイッチ(autoExecuteLowRisk)はフロントとバックの二重で false 固定
  • 最大の難所ライブターミナルとの両立は、xterm を再描画サイクルの外に逃がし、DOM を移植し、ターミナル中は再描画を抑止することで解決した。

「フレームワークを入れない」は手抜きではなく、ローカル管理画面という前提に対する最適化です。状態を一元化し、描画を純関数にし、衝突点(ポーリング・XSS・ライブ端末)を1つずつ設計で潰せば、1ファイルでも破綻しません。

次回(第5回)は、この裏側にある永続化(SQLite)・監査ログ・テスト戦略を扱います。


参照コード(本記事の引用元)

  • main.go(36行)— Wails 起動・Bind//go:embedmain.go:14, main.go:29
  • frontend/src/main.js(約1,465行)— フロント全体
    • 状態:statemain.js:34)、xterm 用モジュール変数(main.js:79
    • 描画:rendermain.js:1122)、shellmain.js:200)、escapeHtmlmain.js:90
    • ポーリング編集保護:refreshDatamain.js:176)、setIntervalmain.js:1464
    • 安全固定:collectMCPFormautoExecuteLowRisk: falsemain.js:1114
    • パスフレーズ:connect-ssh 分岐(main.js:1214)、passphrase-formmain.js:1415
    • ターミナル:mountTerminalmain.js:738)、ensureTerminalmain.js:725)、teardownTerminalmain.js:774)、EventsOnmain.js:788)、b64ToBytesmain.js:718)、renderTerminalmain.js:635
  • wails.json — プロジェクト定義(name: ssh-getefrontend:build 等)

逐行の詳細は docs/解説.md 第10章「フロントエンド main.js の詳説」・付録E・第18.6章を参照。

この記事はオープンソース ssh-gate の紹介記事です。


関連記事


図1.png

クイックイタレート株式会社
IoT / 電力監視 / AI / 衛星・無線通信 / システムインテグレーション/
ローカル LLM・エージェント基盤に関するお問い合わせはお気軽にどうぞ。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?