Пример использования Backbone: админка хранилища JS логов. Часть 1

Содержание

 

Эта статья описывает в подробностях процесс создания одностраничного веб-приложения с использованием Backbone.js. Статья рассчитана на тех, кто уже прошел первое знакомство с Backbone, но хотел бы увидеть побольше практических примеров.
Здесь будут рассмотрены такие практические вопросы как:

  • переопределение методов sync и parse в моделях и коллекциях для кастомной синхронизации с сервером через нестандартное API.
  • выделение атрибута-массива из одиночной модели в отдельную коллекцию со своим представлением (View) и установка связи такой коллекции с моделью-источником.
  • реализация выбора (выделения) элемента коллекции и его представления с сохранением своего состояния.

Исходники рассматриваемого приложения находятся здесь.

Приложение представляет собой админ-панель для одного инструмента ведения JavaScript логов, именуемого JS LogFlush. Суть работы последнего заключается в том, что он перенаправляет вывод console.log в HTTP-запросы и сохраняет его на сервере, группируя в файлах по лог-сессиям. Каждая лог-сессия начинается в момент загрузки страницы и заканчивается при закрытии окна либо по истечении определенного времени. Более подробно о этом инструменте можно почитать здесь.

Наше приложение будет разновидностью файлового менеджера. В его задачи будут входить:

  • регистрация внешних веб-приложений, использующих JS-логгер.
  • вывод списка внешних веб-приложений.
  • удаление веб-приложений.
  • вывод и фильтрация списка лог-файлов, созданных логгером.
  • показ содержимого выбранного лог-файла.
  • удаление лог-файлов.
  • чтение конфигурации (настроек) логгера.
  • изменение конфигурации логгера.

Каждое внешнее веб-приложение задается только его URL адресом, поэтому, чтобы не путать внешние приложения с нашим приложением, далее будем использовать слово «URL» вместо «веб-приложение».

Как следует из его задач, менеджер имеет дело с 3 объектами:

  1. список URL;
  2. список лог-файлов;
  3. конфигурация логгера.

и поддерживает 3 вида операций:

  1. чтение (конфигурации логгера, списков URL и лог-файлов, а также содержимого лог-файлов);
  2. обновление (списка URL и конфигурации логгера);
  3. удаление (лог-файлов).

Как видно, функциональность нашего менеджера несколько ограничена. Так он не позволяет, ни создавать, ни править, ни копировать/перемещать файлы. Эти ограничения следуют из логики работы JS-логгера. Он сам создает лог-файлы и изменяет их содержимое по мере поступления новых данных. Мы же, как пользователи менеджера, можем только просматривать их и удалять ненужные.

Это внешний вид нашего менеджера:
Внешний вид менеджера

Левая колонка содержит список URL, средняя — список лог-файлов, правая — содержимое выбранного лог-файла. Верхняя панель содержит дополнительные элементы управления: поле добавления/регистрации нового URL и кнопки обновления и фильтрации списка лог-файлов и удаления отфильтрованных лог-файлов, а также кнопка вызова конфигурации логгера в виде модального окна. Список лог-файлов может быть отфильтрован по URL, выбранному в списке URL, и/или по IP, который как и URL относится к числу атрибутов лог-файлов. Выбор URL можно снять (отменить), снятие выбора в этом случае означает отсутствие фильтра списка лог-файлов по URL, т.е. отображение всех имеющихся лог-файлов. Для выбора лог-файла снятие неприменимо, т.е. если выбран какой-нибудь лог-файл, то снять этот выбор уже нельзя, можно только выбрать другой файл. Выбор URL и выбор лог-файла сохраняются в localStorage браузера и восстанавливаются при последующих загрузках/обновлениях страницы.

Так в общих чертах выглядит архитектура клиентской части приложения, которую нам предстоит реализовать.

API сервера

Но сначала мы вкратце рассмотрим серверную часть приложения, реализация которой выходит за рамки данной статьи. Предполагаем, что она уже написана (на PHP, хотя язык реализации в данном случае неважен) и нам известно его API. В отличие от клиентской, серверная часть менеджера имеет дело всего с двумя объектами: конфигурация логгера, которая включает в себя список URL, и список лог-файлов. Через эти два объекта осуществляется связь (синхронизация) между серверной и клиентской частями приложения.

API серверной части состоит из двух скриптов "logger_cfg.php" и "logger_ctl.php" и использует нестандартный (для Backbone) протокол взаимодействия, который мы и рассмотрим.

1. "logger_cfg.php" отвечает за синхронизацию конфигурации логгера. Он выполняет два вида операций: чтение и обновление.

Чтение реализуется через GET запрос:
GET /logger_cfg.php
В случае успешного выполнения сервером возвращается HTTP код 200, в случае неудачного выполнения — 404.
Тело ответа сервера в случае успеха выдается в формате JSON (application/json).
Пример успешного ответа:

{
  "dir": "logs\/",
  "app_urls": [
    "http:\/\/somedomain.com\/tests\/test1.html", 
    "http:\/\/somedomain.com\/tests\/test2.html"
  ],
  "buff_size": 10000,
  "interval": 1,
  "interval_bk": 30,
  "expire": 3,
  "requests_limit": 0,
  "log_timeshifts": 1,
  "subst_console": 1,
  "minify": 0
}

Из всех свойств (опций) объекта конфигурации нас в основном будет интересовать свойство "app_urls", соответствующее списку URL.

Обновление реализуется через POST запрос:
POST /logger_cfg.php
Тело (данные) запроса передается в формате JSON (application/json), оно имеет ту же структуру, что и тело ответа на запрос чтения, при этом любое из свойств объекта может быть опущено.
В случае успеха возвращается HTTP код 200, в случае неудачи — 404.
Тело ответа в любом случае пустое.

2. "logger_ctl.php" отвечает за синхронизацию списка лог-файлов. Выполняет два вида операций: чтение и удаление.

Чтение реализуется через GET запрос:
GET /logger_ctl.php
Запрос может содержать параметр с именем "stamp", через который передается временная метка последнего запроса чтения:
GET /logger_ctl.php?stamp=1411030306
В случае успеха возвращается HTTP код 200, в случае неудачи — 404.
Тело ответа сервера в случае успеха выдается в формате JSON (application/json) в виде объекта, имя каждого свойства которого (кроме последнего) содержит имя лог-файла, а значение — упорядоченный массив атрибутов лог-файла, в число которых входят:

  1. URL веб-приложения
  2. временная метка (timestamp) создания
  3. IP клиента логгера
  4. UserAgent клиента логгера

Последнее по порядку свойство объекта с именем "stamp" содержит временную метку текущего запроса, которая используется клиентской частью в последующих запросах чтения.
Пример успешного ответа:

{
  "5411d3dc39f14.log": [
    "http:\/\/somedomain.com\/tests\/test1.html",
    "1410454492",
    "127.0.0.1",
    "Mozilla\/5.0 (Windows NT 6.1; WOW64; rv:32.0) Gecko\/20100101 Firefox\/32.0"
  ],
  "54198a97a8f6d.log": [
    "http:\/\/somedomain.com\/tests\/test2.html",
    "1410960023",
    "127.0.0.1",
    "Mozilla\/5.0 (Windows NT 6.1; WOW64; rv:32.0) Gecko\/20100101 Firefox\/32.0"
  ],
  "stamp": 1411030306
}

Удаление реализуется через POST запрос:
POST /logger_ctl.php
Тело запроса передается в формате "application/x-www-form-urlencoded" и содержит только один параметр с именем "id", соответствующий имени удаляемого лог-файла.
Пример тела запроса:
id=5411d3dc39f14.log
Ответ сервера в любом случае возвращается с HTTP кодом 200 и с пустым телом. После запроса удаления вне зависимости от его результата клиентская часть будет обновлять список лог-файлов с помощью запроса чтения. Поэтому ответ сервера на запрос удаления не имеет значения.

С серверной частью разобрались, переходим собственно к предмету статьи — реализации клиентской логики нашего приложения.

Реализация клиентской части

В качестве основы для верстки будем использовать один из бутстраповских примеров — Dashboard. Он хорошо подходит к нашей задаче. Нужно только добавить еще одну колонку и немного подправить стили.

HTML код приложения:

<div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
  <div class="container-fluid">
    <div class="navbar-header">
      <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
        <span class="sr-only">Toggle navigation</span>
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
      </button>
      <a class="navbar-brand" href="#about" data-toggle="modal">Dashboard</a>
    </div>
    <div class="navbar-collapse collapse">
      <form class="navbar-form navbar-left" id="controls" onsubmit="return false">
        <input type="text" class="form-control" placeholder="New web app" title="Press &lt;Enter&gt; to register new web app with entered URL">
        <button type="button" class="btn btn-primary filter" title="Filter log files by IP">
          <span class="glyphicon glyphicon-filter"></span> IP<span class="value"></span>
        </button>
        <button type="button" class="btn btn-default refresh" title="Refresh the list of log files">
          <span class="glyphicon glyphicon-refresh"></span>
        </button>
        <button type="button" class="btn btn-default remove" title="Remove all visible log files">
          <span class="glyphicon glyphicon-remove"></span>
        </button>
      </form>
      <ul class="nav navbar-nav navbar-right">
        <li><a href="#config" data-toggle="modal"><span class="glyphicon glyphicon-cog"></span> Config</a></li>
      </ul>
    </div>
  </div>
</div>

<div class="container-fluid">
  <div class="row">
    <div class="col-sm-4 col-md-3 sidebar">
      <ul class="nav nav-pills nav-stacked" id="url-list"></ul>
    </div>

    <div class="col-sm-3 col-sm-offset-4 col-md-2 col-md-offset-3 sidebar">
      <ul class="nav nav-pills nav-stacked" id="file-list"></ul>
    </div>

    <div class="col-sm-5 col-sm-offset-7 col-md-7 col-md-offset-5 main">
      <div id="file-content"></div>
    </div>
  </div>
</div>

<script type="text/javascript" src="//code.jquery.com/jquery-1.11.1.min.js"></script>
<script type="text/javascript" src="//cdnjs.cloudflare.com/ajax/libs/underscore.js/1.5.2/underscore-min.js"></script>
<script type="text/javascript" src="//cdnjs.cloudflare.com/ajax/libs/backbone.js/1.1.2/backbone-min.js"></script>
<script src="//maxcdn.bootstrapcdn.com/bootstrap/3.1.1/js/bootstrap.min.js"></script>
<script type="text/javascript" src="manager.js"></script>

ul#url-list будет содержать список URL, ul#file-list — список лог-файлов, а div#file-content — содержимое выбранного файла. Модальное окно для конфигурации логгера мы добавим позднее. В конце мы подключаем необходимые библиотеки и код нашего приложения ("manager.js").

Как уже упоминалось, клиентская часть менеджера имеет дело с 3 основными объектами: конфигурация логгера, список URL и список лог-файлов. К ним также добавятся 4 дополнительных объекта: выбор URL, выбор лог-файла, содержимое выбранного лог-файла и фильтр лог-файлов по IP. Каждый из перечисленных объектов будет реализован в виде Backbone модели или коллекции и будет иметь свое представление (View).

Модели/коллекции

Начнем с объявления и реализации моделей и коллекций.

Конфигурация логгера

Конфигурация логгера будет одиночной моделью, не входящей в коллекцию. Набор ее атрибутов определяется API, которое мы уже рассмотрели. Мы не будем строить объект defaults значений по умолчанию, т.к. атрибутов довольно много, к тому же их состав может и измениться. Метод initialize нам тоже пока не нужен. Что нам нужно, так это метод sync, переопределяющий поведение метода Backbone.sync для нашей модели, поскольку у нас нестандартное API. Официальный мануал не объясняет, как именно нужно переопределять этот метод (оно и понятно, ведь это зависит от конкретного API). Впрочем нам это и не нужно. Мы можем использовать оригинальную реализацию этого метода, заменив в ней те части, которые зависят от API. Для этого нам придется заглянуть в исходники Backbone.

Вот исходный код метода Backbone.sync для версии 1.1.2, которую мы используем (впрочем в других версиях ветки 1.x он не сильно отличается). Нам нужны только 3 последние строки метода. Их мы можем скопировать к себе в метод без изменений. Все, что находится выше, подготавливает объект params параметров для обертки метода $.ajax с расчетом на REST API. Все это мы заменим своим кодом, рассчитанным на наше API.

Класс и объект модели конфигурации:

var Cfg = Backbone.Model.extend({
  sync: function(method, model, options){
    // строим объект параметров ajax-запроса к нашему API
    var params = method == 'read'?
      {
        type: 'GET', url: urlCfg, dataType: 'json'
      } :
      {// если не read, то create/update
        type: 'POST', url: urlCfg,
        contentType: 'application/json',
        data: JSON.stringify(options.attrs || model.toJSON(options))
      };
    var xhr = options.xhr = Backbone.ajax(_.extend(params, options));
    model.trigger('request', model, xhr, options);
    return xhr;
  },
});
var cfg = new Cfg(deftCfg);

Метод sync принимает 3 параметра:

  • method — название CRUD метода;
  • model — синхронизируемая модель;
  • options — объект опций, передаваемый в sync.

У нас будет всего два CRUD метода: "read" — для чтения и "create" — для обновления. Почему "create", а не "update"? Потому что у нашей модели нет атрибута "id". Если у модели на момент ее сохранения на сервер нет атрибута "id", Backbone считает, что это операция создания ("create"). Это вполне логично, если учесть, что CRUD строится на связях с БД и рассчитан прежде всего на коллекции, где каждый элемент (модель) имеет свой уникальный идентификатор. Но у нас не коллекция, а одиночная модель. Поэтому нам не нужен ID, чтобы идентифицировать ее. Ну а поскольку нам не требуется различать операции "create" и "update" (мы реализуем только одну из них), то можно просто не обращать на это внимания: неважно как называется операция на клиенте, важно как она преобразуется в запрос к серверу.

Переменная urlCfg будет передаваться скрипту из веб-страницы приложения.

После объявления класса модели (Cfg) мы сразу же создаем его объект/экземпляр в переменной cfg. Здесь возникает вопрос: как нам заполнить этот объект начальными данными? Есть соблазн использовать для этого вызов метода fetch, который запустит наш метод sync для синхронизации с сервером. Но оф.мануал убеждает нас, что так делать некошерно. Вместо этого нам нужно загрузить начальные данные на месте, во время загрузки страницы, и передать их в конструктор модели. Для этого мы делаем аналог запроса чтения конфигурации внутри скрипта, генерирующего html-страницу, и выводим его результат в JS переменную deftCfg, которую и передаем в конструктор.

Список URL

Как упоминалось в начале статьи, список URL будет коллекцией, связанной с атрибутом "app_urls" модели конфигурации. Для чего нужно выделять атрибут модели в коллекцию? Для того, чтобы он имел свое отдельное представление (View). Исходя из проекта нашего приложения модель конфигурации будет иметь представление в виде модального окна. Но мы не хотим, чтобы в него входил список URL. Для этого атрибута мы хотим построить отдельное представление в виде колонки слева от списка лог-файлов. К сожалению Backbone пока не позволяет иметь отдельные представления для разных атрибутов одной модели. Поэтому нам придется создать «фиктивную» коллекцию, которая будет отражать состояние выбранного атрибута модели-источника. Эта коллекция не будет иметь внешней синхронизации с сервером, вместо этого будет использоваться внутренняя синхронизация — связь с моделью-источником. Для обеспечения этой связи нам опять понадобится свой метод sync переопределяющий поведение метода Backbone.sync для нашей коллекции. А конкретно, этот метод будет подменять запросы к серверу операциями внутри приложения, реализующими связь между коллекцией и моделью-источником.

Но сначала нам нужно объявить модель URL — основу для нашей коллекции. Модель URL будет соответствовать элементу массива "app_urls" из модели конфигурации, значение которого (т.е. значение URL) будет содержаться в одном из атрибутов модели. Лучше всего назвать этот атрибут именем "id". Нам все равно понадобится атрибут с этим именем, если мы хотим иметь поддержку операции удаления экземпляров этой модели, которая в противном случае не будет работать. Можно, конечно, иметь в качестве ID отдельный атрибут (например числовой), помимо URL. Но в таком случае нам придется позаботиться о его уникальности, что не так-то просто сделать, если учесть, что источником данных является не БД, а простой массив значений. С другой же стороны URL по определению уникален, что делает его лучшим кандидатом на роль идентификатора. Кроме атрибута "id", содержащего значение URL, нам понадобится дополнительный атрибут "title" для вывода URL в сокращенном виде в списке URL.

Класс модели URL:

var Url = Backbone.Model.extend({
  defaults: {
    id: '',
    title: ''
  }
});

Теперь на основе этой модели мы создадим коллекцию — Список URL. Начнем с реализации (переопределения) метода sync. Метод sync коллекции Список URL будет принимать те же параметры, что его аналог в модели Cfg, с тем отличием, что в качестве 2-го параметра будет приходить не модель, а коллекция.

В этот раз мы уже не можем использовать оригинальную реализацию Backbone.sync ввиду отсутствия у нас настоящей синхронизации с сервером. Наша коллекция будет использовать внутреннюю синхронизацию без использования HTTP (AJAX) запросов. Пример такой синхронизации можно найти в расширении Backbone localStorage.

Вот исходный код метода Backbone.sync из свежей версии этого расширения, который служит для синхронизации моделей/коллекций с HTML5 localStorage. Его мы можем использовать в качестве основы для нашего метода. Нужно только заменить части кода, имеющие дело с HTML5 localStorage, своим кодом, который будет иметь дело с моделью-источником.

Класс и объект коллекции Список URL:

var UrlList = Backbone.Collection.extend({
  model: Url,
  initialize: function() {
    this.listenTo(cfg, 'sync', this.onCfgSync);
  },
  onCfgSync: function(model, resp, options) {
    if (resp) {
      // если ответ не пустой, значит это операция чтения (конфигурации);
      // обновляем список URL данными из конфигурации
      // (через вызов нашего кастомного метода sync):
      this.fetch();
    }
    else {
      // если ответ пустой, значит это операция сохранения (конфигурации);
      // обновляем модель конфигурации данными с сервера
      cfg.fetch();
    }
  },
  sync: function(method, list, options){
    var resp; // ответ на запрос синхр-ции
    var errMsg = 'Sync error'; // сообщение об ошибке
    // объект Deferred
    var dfd = Backbone.$ ?
      (Backbone.$.Deferred && Backbone.$.Deferred()) :
      (Backbone.Deferred && Backbone.Deferred());
    // если массив app_urls непустой
    if (cfg.get('app_urls')) {
      // строим ответ на запрос чтения:
      // преобразуем массив app_urls в хэш из наборов атрибутов
      resp = _.map(cfg.get('app_urls'), cfg.buildUrlAttrs);
    }
    if (resp) {
      if (options && options.success) options.success(resp);
      if (dfd) dfd.resolve(resp);
    } else {
      if (options && options.error) options.error(errMsg);
      if (dfd) dfd.reject(errMsg);
    }
    if (options && options.complete) options.complete(resp);
    return dfd && dfd.promise();
  },
  // строит набор значений атрибутов для инициализации модели URL
  buildUrlAttrs: function(url){
    var title = url.substr(url.indexOf('://')+3);
    if (title.length > 35) title = title.substr(0, 35)+ '...';
    return {id: url, title: title};
  }
});
var urlList = new UrlList();

В методе initialize мы устанавливаем обработчик onCfgSync для события sync модели конфигурации. В нем мы обновляем Список URL после каждой синхронизации модели конфигурации с сервером. Кроме того, здесь мы обновляем саму модель конфигурации после ее сохранения на сервере. Это необходимо для подтверждения результатов операции сохранения, т.к. у нас нет гарантии ее успешного выполнения. Обработчик принимает 3 параметра:

  • model — синхронизируемая модель, т.е. Cfg;
  • resp — ответ сервера;
  • options — объект опций, который был передан в sync.

Из них используется только параметр resp, содержащий ответ сервера на запрос синхронизации, для определения типа операции (чтение или сохранение). Обработку операции сохранения мы поместили здесь для экономии места, вместо этого ее можно было бы перенести в класс конфигурации.

Параметр method метода sync может принимать только одно значение — "read". Причина в том, что метод sync у коллекций вызывается только при операции чтения данных из источника (объяснение можно найти здесь). Все остальные операции ("create", "update", "delete") делегируются каждому элементу коллекции, в данном случае — модели URL.

Это значит, что для поддержки недостающих операций нам придется также переопределить метод sync в модели URL. Из недостающих операций мы будем поддерживать операции создания ("create") и удаления ("delete"). Операцию обновления ("update") не поддерживаем, т.к для URL она у нас не используется.

На данном этапе нам нужно провести небольшой рефакторинг нашего кода, пока он еще не слишком разросся. Дело в том, что у нас теперь будут два похожих метода sync в разных классах: Url и UrlList. Оба они будут содержать частично один и тот же код довольно больших размеров, отличаться будет только код, зависящий от типа операции. Здесь напрашивается объединение этих методов в один с разделением логики в зависимости от типа операции (параметра method). Тем более, что в обоих методах не используется указатель this. Вопрос только в том куда поместить этот метод? Самое разумное решение — перенести его в класс Cfg, поскольку экземпляр последнего cfg используется в этом методе. Тогда нам необходимо переименовать этот метод в другое имя (syncUrls), поскольку имя sync уже занято в данном классе, а также заменить в нем все ссылки на cfg указателем this. Кроме того, необходимо перенести туда же метод buildUrlAttrs, строящий атрибуты для инициализации модели URL.
В результате у нас получится следующее:

Текущий код приложения:

var Cfg = Backbone.Model.extend({
  sync: function(method, model, options){
    // строим объект параметров ajax-запроса к нашему API
    var params = method == 'read'?
      {
        type: 'GET', url: urlCfg, dataType: 'json'
      } :
      {// если не read, то create/update
        type: 'POST', url: urlCfg,
        contentType: 'application/json',
        data: JSON.stringify(options.attrs || model.toJSON(options))
      };
    var xhr = options.xhr = Backbone.ajax(_.extend(params, options));
    model.trigger('request', model, xhr, options);
    return xhr;
  },
  // строит набор значений атрибутов для инициализации модели URL
  buildUrlAttrs: function(url){
    var title = url.substr(url.indexOf('://')+3);
    if (title.length > 35) title = title.substr(0, 35)+ '...';
    return {id: url, title: title};
  }
  // объединенный метод sync классов Url и UrlList:
  syncUrls: function(method, model, options){
    var resp, errMsg = 'Sync error';
    var dfd = Backbone.$ ?
      (Backbone.$.Deferred && Backbone.$.Deferred()) :
      (Backbone.Deferred && Backbone.Deferred());
    var urls = this.get('app_urls'), url;
    if (urls)
    switch (method) {
    case 'read':
      // строим ответ на запрос чтения:
      // преобразуем массив app_urls в хэш из наборов атрибутов
      resp = _.map(urls, this.buildUrlAttrs);
      break;
    case 'create': case 'update':
      // атрибут URL новой модели URL:
      url = model.attributes.id;
      // если новый URL не содержится в массиве app_urls:
      if (_.indexOf(urls, url) < 0)
        // добавляем его в массив app_urls
        // и сохраняем модель конфигурации на сервер:
        this.save({app_urls: _.union(urls, url)});
      break;
    case 'delete':
      // атрибут URL удаляемой модели URL:
      url = model.attributes.id;
      // если новый URL содержится в массиве app_urls:
      if (_.indexOf(urls, url) >= 0)
        // удаляем его из массива app_urls
        // и сохраняем модель конфигурации на сервер:
        this.save({app_urls: _.without(urls, url)});
      break;
    default:
    }
    if (resp) {
      if (options && options.success) options.success(resp);
      if (dfd) dfd.resolve(resp);
    } else {
      if (options && options.error) options.error(errMsg);
      if (dfd) dfd.reject(errMsg);
    }
    if (options && options.complete) options.complete(resp);
    return dfd && dfd.promise();
  }
});
var cfg = new Cfg(deftCfg);

var Url = Backbone.Model.extend({
  defaults: {
    id: '',
    title: ''
  },
  sync: function(method, model, options){
    return cfg.syncUrls(method, model, options);
  }
});

var UrlList = Backbone.Collection.extend({
  model: Url,
  initialize: function() {
    this.listenTo(cfg, 'sync', this.onCfgSync);
  },
  sync: function(method, list, options){
    return cfg.syncUrls(method, list, options);
  },
  onCfgSync: function(model, resp, options) {
    if (resp) {
      // если ответ не пустой, значит это операция чтения (конфигурации);
      // обновляем список URL данными из конфигурации
      // (через вызов нашего кастомного метода sync):
      this.fetch();
    }
    else {
      // если ответ пустой, значит это операция сохранения (конфигурации);
      // обновляем модель конфигурации данными с сервера
      cfg.fetch();
    }
  }
});
var urlList = new UrlList();

 

Продолжение следует…