Commit d9014ba8 authored by Tim Zallmann's avatar Tim Zallmann

Merge branch '4268-geo-admin-page' into 'master'

Geo admin screen enhancements

Closes #4268, #4179, and #1363

See merge request gitlab-org/gitlab-ee!3902
parents aab4fd0f 862ad724
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 310 271" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="0" width="7" height="25" rx="3.5"/><rect id="2" width="9" height="13" x="3" y="3" rx="3"/><rect id="4" width="15" height="6" rx="3"/><mask id="1" width="7" height="25" x="0" y="0" fill="#fff"><use xlink:href="#0"/></mask><mask id="3" width="9" height="13" x="0" y="0" fill="#fff"><use xlink:href="#2"/></mask><mask id="5" width="15" height="6" x="0" y="0" fill="#fff"><use xlink:href="#4"/></mask></defs><g fill="none" fill-rule="evenodd"><g transform="translate(30 166)"><path stroke="#eee" stroke-width="3" d="m219.69 85.73c-12.712-48.19-56.6-83.73-108.79-83.73-52.35 0-96.35 35.757-108.91 84.18" stroke-linecap="round"/><g fill="#eee"><g fill-rule="nonzero"><path d="m111.12 3.5c50.613.098 94.39 34.36 107.12 82.61.211.801 1.032 1.279 1.833 1.068.801-.211 1.279-1.032 1.068-1.833-13.07-49.558-58.03-84.74-110.01-84.85-.828-.002-1.501.669-1.503 1.497-.002.828.669 1.501 1.497 1.503m64.11 82.09c-7.683-49.66-33.691-84.992-64.11-85.09-.828-.003-1.502.667-1.505 1.495-.003.828.667 1.502 1.495 1.505 28.581.096 53.665 34.17 61.15 82.55.127.819.893 1.38 1.712 1.253.819-.127 1.38-.893 1.253-1.712m-64.27-85.09c-52.31 0-97.49 35.442-110.42 85.3-.208.802.274 1.621 1.075 1.828.802.208 1.621-.274 1.828-1.075 12.588-48.547 56.58-83.06 107.45-83.06.888 0 1.56-.671 1.56-1.499 0-.828-.671-1.5-1.499-1.501m-61.28 85.81c7.442-48.563 32.608-82.79 61.28-82.81.828-.001 1.499-.673 1.499-1.501-.001-.828-.673-1.499-1.501-1.499-30.512.028-56.6 35.51-64.24 85.36-.125.819.437 1.584 1.255 1.71.819.125 1.584-.437 1.71-1.255"/><path d="m33.06 33.907c21.698 11.943 49.04 18.593 77.938 18.593 28.84 0 56.14-6.624 77.82-18.527.726-.399.992-1.311.593-2.037-.399-.726-1.311-.992-2.037-.593-21.23 11.653-48.03 18.16-76.37 18.16-28.402 0-55.25-6.529-76.49-18.221-.726-.399-1.638-.135-2.037.591-.399.726-.135 1.638.591 2.037"/></g><rect width="3" height="86" x="110" y="2" rx="1.5"/></g></g><g transform="translate(133 160)"><circle cx="8.5" cy="8.5" r="8.5" fill="#fc6d26" opacity=".2"/><circle cx="8.5" cy="8.5" r="3.5" fill="#fff" stroke="#fc6d26" stroke-width="3" stroke-linecap="round"/></g><g transform="translate(24 45)"><path fill="#fde5d8" d="m215.76 178.82c1.077-1.716 2.108-3.461 3.094-5.231.403-.724.143-1.637-.581-2.04-.724-.403-1.637-.143-2.04.581-.96 1.725-1.965 3.424-3.01 5.096-.44.702-.228 1.628.474 2.068.702.44 1.628.228 2.068-.474"/><g fill="#fc8a51"><path d="m224.21 162.66c.794-1.863 1.54-3.747 2.236-5.651.284-.778-.116-1.639-.894-1.924-.778-.285-1.639.116-1.924.894-.678 1.854-1.405 3.69-2.178 5.505-.325.762.029 1.643.791 1.968.762.325 1.643-.029 1.968-.791" opacity=".2"/><path d="m230.02 145.37c.491-1.964.932-3.943 1.32-5.934.159-.813-.372-1.601-1.185-1.76-.813-.159-1.601.372-1.76 1.185-.379 1.94-.808 3.868-1.286 5.781-.201.804.287 1.618 1.091 1.819.804.201 1.618-.287 1.819-1.091" opacity=".3"/><path d="m233.04 127.41c.178-2.01.305-4.04.38-6.07.03-.828-.616-1.524-1.444-1.554-.828-.03-1.524.616-1.554 1.444-.073 1.978-.196 3.949-.37 5.911-.073.825.537 1.553 1.362 1.627.825.073 1.553-.537 1.627-1.362" opacity=".4"/><path d="m233.24 109.18c-.134-2.026-.321-4.04-.559-6.05-.098-.823-.844-1.41-1.666-1.313-.823.098-1.41.844-1.313 1.666.232 1.956.414 3.922.545 5.896.055.827.769 1.452 1.596 1.397.827-.055 1.452-.769 1.397-1.596" opacity=".5"/><path d="m230.62 91.14c-.449-1.98-.949-3.945-1.5-5.894-.225-.797-1.054-1.261-1.851-1.035-.797.225-1.261 1.054-1.035 1.851.537 1.899 1.024 3.813 1.461 5.742.183.808.987 1.314 1.794 1.131.808-.183 1.314-.987 1.131-1.794" opacity=".6"/><path d="m225.2 73.74c-.753-1.882-1.555-3.742-2.405-5.581-.348-.752-1.239-1.08-1.991-.732-.752.348-1.08 1.239-.732 1.991.828 1.791 1.609 3.603 2.343 5.436.308.769 1.181 1.143 1.95.835.769-.308 1.143-1.181.835-1.95" opacity=".7"/><path d="m217.11 57.38c-1.038-1.739-2.121-3.452-3.249-5.135-.461-.688-1.393-.873-2.081-.412-.688.461-.873 1.393-.412 2.081 1.098 1.64 2.154 3.308 3.165 5 .425.711 1.345.944 2.057.519.711-.425.944-1.345.519-2.057" opacity=".8"/><path d="m206.55 42.478c-1.297-1.556-2.633-3.079-4.01-4.566-.563-.608-1.512-.645-2.12-.083-.608.563-.645 1.512-.083 2.12 1.34 1.449 2.643 2.933 3.906 4.449.53.636 1.476.723 2.113.192.636-.53.723-1.476.192-2.113" opacity=".9"/><path d="m193.89 29.473c-1.521-1.337-3.077-2.635-4.666-3.892-.65-.514-1.593-.404-2.107.246-.514.65-.404 1.593.246 2.107 1.548 1.224 3.064 2.489 4.546 3.792.622.547 1.57.486 2.117-.136.547-.622.486-1.57-.136-2.117"/></g><path fill="#fde5d8" d="m179.28 18.527c-1.711-1.085-3.451-2.124-5.216-3.118-.722-.406-1.637-.151-2.043.571-.406.722-.151 1.637.571 2.043 1.72.968 3.415 1.981 5.082 3.038.7.443 1.626.236 2.07-.464.443-.7.236-1.626-.464-2.07m-16.14-8.534c-1.86-.803-3.741-1.557-5.642-2.262-.777-.288-1.64.108-1.928.885-.288.777.108 1.64.885 1.928 1.852.687 3.684 1.421 5.496 2.203.761.328 1.643-.022 1.972-.783.328-.761-.022-1.643-.783-1.972m-17.291-5.894c-1.962-.5-3.938-.948-5.928-1.345-.812-.162-1.602.365-1.764 1.178-.162.812.365 1.602 1.178 1.764 1.938.387 3.863.824 5.774 1.31.803.205 1.619-.28 1.824-1.083.205-.803-.28-1.619-1.083-1.824m-17.931-3.095c-2.01-.187-4.03-.322-6.06-.406-.828-.034-1.526.61-1.56 1.437-.034.828.61 1.526 1.437 1.56 1.978.081 3.948.213 5.909.395.825.077 1.556-.53 1.632-1.355.077-.825-.53-1.556-1.355-1.632m-18.14-.285c-2.028.124-4.048.3-6.06.528-.823.093-1.415.836-1.321 1.66.093.823.836 1.415 1.66 1.321 1.959-.222 3.927-.394 5.903-.515.827-.051 1.456-.762 1.406-1.589-.051-.827-.762-1.456-1.589-1.406m-18.05 2.528c-1.982.438-3.949.928-5.901 1.468-.798.221-1.266 1.048-1.045 1.846.221.798 1.048 1.266 1.846 1.045 1.901-.527 3.817-1 5.748-1.43.809-.179 1.32-.98 1.141-1.788-.179-.809-.98-1.32-1.788-1.141m-17.426 5.323c-1.887.743-3.753 1.536-5.597 2.377-.754.344-1.086 1.233-.742 1.987.344.754 1.233 1.086 1.987.742 1.796-.819 3.614-1.591 5.452-2.315.771-.304 1.149-1.175.846-1.945-.304-.771-1.175-1.149-1.945-.846"/><g fill="#6b4fbb"><path d="m57.904 16.58c-1.746 1.03-3.464 2.104-5.154 3.224-.691.457-.88 1.388-.422 2.079.457.691 1.388.88 2.079.422 1.646-1.09 3.32-2.138 5.02-3.141.714-.421.951-1.34.53-2.054-.421-.714-1.34-.951-2.054-.53" opacity=".2"/><path d="m42.949 27.06c-1.563 1.289-3.093 2.618-4.588 3.986-.611.559-.653 1.508-.094 2.119.559.611 1.508.653 2.119.094 1.456-1.333 2.947-2.628 4.47-3.884.639-.527.73-1.472.203-2.112-.527-.639-1.472-.73-2.112-.203" opacity=".3"/><path d="m29.871 39.663c-1.346 1.515-2.653 3.066-3.918 4.649-.517.647-.412 1.591.235 2.108.647.517 1.591.412 2.108-.235 1.233-1.543 2.506-3.053 3.818-4.53.55-.619.494-1.567-.125-2.118-.619-.55-1.567-.494-2.118.125" opacity=".4"/><path d="m18.852 54.21c-1.093 1.706-2.142 3.44-3.145 5.2-.41.72-.159 1.636.561 2.046.72.41 1.636.159 2.046-.561.977-1.715 1.999-3.405 3.064-5.066.447-.697.244-1.625-.453-2.072-.697-.447-1.625-.244-2.072.453" opacity=".5"/><path d="m10.236 70.3c-.813 1.856-1.577 3.733-2.292 5.63-.292.775.1 1.64.875 1.933.775.292 1.64-.1 1.933-.875.696-1.848 1.441-3.677 2.232-5.484.332-.759-.013-1.643-.772-1.976-.759-.332-1.643.013-1.976.772" opacity=".6"/><path d="m4.248 87.58c-.51 1.959-.969 3.934-1.376 5.922-.166.812.357 1.604 1.168 1.771.812.166 1.604-.357 1.771-1.168.397-1.937.844-3.86 1.34-5.768.209-.802-.272-1.621-1.074-1.829-.802-.209-1.621.272-1.829 1.074" opacity=".7"/><path d="m1.061 105.5c-.197 2.01-.343 4.03-.436 6.06-.038.828.602 1.529 1.43 1.567.828.038 1.529-.602 1.567-1.43.091-1.977.233-3.947.425-5.907.081-.824-.522-1.558-1.347-1.639-.824-.081-1.558.522-1.639 1.347" opacity=".8"/><path d="m.685 123.61c.114 2.029.279 4.049.497 6.06.089.824.829 1.419 1.653 1.33.824-.089 1.419-.829 1.33-1.653-.212-1.96-.374-3.929-.484-5.906-.046-.827-.754-1.46-1.582-1.414-.827.046-1.46.754-1.414 1.582" opacity=".9"/><path d="m3.12 141.68c.428 1.982.907 3.951 1.437 5.904.217.8 1.041 1.272 1.841 1.055.8-.217 1.272-1.041 1.055-1.841-.516-1.902-.983-3.82-1.4-5.751-.175-.81-.973-1.325-1.783-1.15-.81.175-1.325.973-1.15 1.783"/></g><path fill="#fde5d8" d="m8.361 159.16c.734 1.89 1.517 3.76 2.348 5.607.34.755 1.228 1.092 1.983.752.755-.34 1.092-1.228.752-1.983-.81-1.8-1.573-3.621-2.288-5.462-.3-.772-1.169-1.155-1.941-.855-.772.3-1.155 1.169-.855 1.941m7.911 16.416c1.02 1.751 2.086 3.474 3.196 5.169.454.693 1.384.887 2.077.433.693-.454.887-1.384.433-2.077-1.081-1.651-2.12-3.331-3.113-5.04-.417-.716-1.335-.958-2.051-.541-.716.417-.958 1.335-.541 2.051m10.407 15.02c1.281 1.57 2.602 3.106 3.962 4.608.556.614 1.505.661 2.119.104.614-.556.661-1.505.104-2.119-1.325-1.463-2.613-2.96-3.86-4.49-.524-.642-1.469-.738-2.11-.214-.642.524-.738 1.469-.214 2.11"/></g><g transform="translate(146 82)"><path fill="#fde5d8" d="m3.229 76.861c.048-.069.134-.195.253-.376.194-.294.416-.641.661-1.038.696-1.126 1.447-2.437 2.225-3.92.385-.733.103-1.64-.631-2.025-.733-.385-1.64-.103-2.025.631-.746 1.42-1.462 2.67-2.122 3.739-.229.371-.434.692-.612.962-.104.158-.174.26-.207.307-.475.679-.311 1.614.368 2.089.679.475 1.614.311 2.089-.368"/><path fill="#fdc4a8" d="m11.208 60.31c.661-1.911 1.257-3.861 1.781-5.849.211-.801-.267-1.622-1.068-1.833-.801-.211-1.622.267-1.833 1.068-.504 1.913-1.079 3.792-1.715 5.633-.271.783.144 1.637.927 1.908.783.271 1.637-.144 1.908-.927"/><path fill="#fc9562" d="m15.16 42.4c.197-2.033.309-4.073.331-6.12.009-.828-.655-1.507-1.484-1.516-.828-.009-1.507.655-1.516 1.484-.021 1.96-.128 3.914-.317 5.862-.08.825.523 1.558 1.348 1.638.825.08 1.558-.523 1.638-1.348"/><path fill="#fde5d8" d="m14.533 24.07c-.343-2.01-.779-4.01-1.311-5.996-.214-.8-1.036-1.276-1.836-1.062-.8.214-1.276 1.036-1.062 1.836.508 1.902.924 3.812 1.252 5.728.14.817.915 1.365 1.731 1.226.817-.14 1.365-.915 1.226-1.731m-5.533-17.538c-.855-1.824-1.798-3.635-2.829-5.431-.412-.719-1.329-.967-2.048-.554-.719.412-.967 1.329-.554 2.048.99 1.725 1.894 3.462 2.714 5.212.352.75 1.245 1.073 1.995.721.75-.352 1.073-1.245.721-1.995"/></g><g transform="translate(86)"><rect width="84" height="91" x="36.806" y="10.487" fill="#fafafa" transform="matrix(.99619-.08716.08716.99619-4.58 7.081)" rx="12"/><rect width="84" height="91" x="31.806" y="6.487" fill="#fff" stroke="#eee" stroke-width="3" stroke-linecap="round" transform="matrix(.99619-.08716.08716.99619-4.25 6.63)" rx="10"/><rect width="88" height="98" x="11.84" y="11.896" fill="#fafafa" transform="matrix(.98481.17365-.17365.98481 11.423-8.771)" rx="12"/><g transform="matrix(.98481.17365-.17365.98481 17.233 0)"><rect width="88" height="98" fill="#fff" stroke="#eee" stroke-width="3" stroke-linecap="round" rx="10"/><g transform="translate(14 20)"><g fill="#fde5d8"><rect width="12" height="4" rx="2"/><rect width="12" height="4" x="24" y="12" rx="2"/></g><rect width="12" height="4" y="36" fill="#6b4fbb" rx="2"/><g fill="#fde5d8"><rect width="12" height="4" x="18" y="36" rx="2"/><rect width="12" height="4" y="54" rx="2"/></g><rect width="12" height="4" x="36" y="36" fill="#fc6d26" rx="2"/><rect width="6" height="4" x="42" y="12" fill="#6b4fbb" rx="2"/><g fill="#fde5d8"><rect width="6" height="4" x="36" y="24" rx="2"/><rect width="6" height="4" x="54" y="36" rx="2"/><rect width="6" height="4" x="48" y="24" rx="2"/><rect width="6" height="4" x="42" y="54" rx="2"/><rect width="6" height="4" y="24" rx="2"/><rect width="18" height="4" x="18" rx="2"/></g><rect width="18" height="4" x="18" y="54" fill="#fc6d26" rx="2"/><rect width="18" height="4" y="12" fill="#fc8a51" rx="2"/><g fill="#fc6d26"><rect width="18" height="4" x="12" y="24" rx="2"/><rect width="12" height="4" x="42" rx="2"/></g></g></g></g><g transform="translate(230 214)"><circle cx="8.5" cy="8.5" r="8.5" fill="#fc6d26" opacity=".2"/><circle cx="8.5" cy="8.5" r="3.5" fill="#fff" stroke="#fc6d26" stroke-width="3" stroke-linecap="round"/></g><g transform="translate(63 242)"><g fill="#6b4fbb"><circle cx="14.5" cy="14.5" r="14.5" opacity=".1"/><circle cx="14.5" cy="14.5" r="10.5" opacity=".2"/></g><circle cx="14.5" cy="14.5" r="4.5" fill="#fff" stroke="#6b4fbb" stroke-width="3" stroke-linecap="round"/></g><circle cx="191.5" cy="210.5" r="4.5" fill="#fff" stroke="#fde5d8" stroke-width="3" stroke-linecap="round"/><g transform="translate(251 177)"><path fill="#fafafa" d="m12.877 44.501c4.637 6.364 12.147 10.499 20.623 10.499 14.08 0 25.5-11.417 25.5-25.5 0-14.08-11.417-25.5-25.5-25.5-14.08 0-25.5 11.417-25.5 25.5 0 1.798.186 3.553.54 5.246l-3.651 7.385c-.489.989.007 1.855 1.112 1.927l6.876.443"/><path fill="#fff" stroke="#e5e5e5" stroke-width="3" d="m8.877 40.501c4.637 6.364 12.147 10.499 20.623 10.499 14.08 0 25.5-11.417 25.5-25.5 0-14.08-11.417-25.5-25.5-25.5-14.08 0-25.5 11.417-25.5 25.5 0 1.798.186 3.553.54 5.246l-3.651 7.385c-.489.989.007 1.855 1.112 1.927l6.876.443" stroke-linecap="round" stroke-linejoin="round"/><g transform="translate(17 18)"><g stroke="#fc6d26"><path fill="#fff" stroke-width="3" d="m13 16c7.18 0 12.367-6.825 12.367-6.825.35-.417.369-1.116.036-1.547 0 0-5.224-7.629-12.404-7.629-7.18 0-12.404 7.629-12.404 7.629-.329.438-.316 1.138.049 1.561 0 0 5.175 6.811 12.355 6.811" stroke-linecap="round" opacity=".5"/><circle cx="13" cy="8" r="4" stroke-width="2"/></g><circle cx="13" cy="8" r="2" fill="#fc6d26"/></g></g><g transform="matrix(-1 0 0 1 62 198)"><path fill="#fafafa" d="m9.837 49.647c5.04 5.734 12.429 9.353 20.663 9.353 15.188 0 27.5-12.312 27.5-27.5 0-15.188-12.312-27.5-27.5-27.5-15.188 0-27.5 12.312-27.5 27.5 0 2.928.458 5.749 1.305 8.396l-3.613 7.306c-.489.989.007 1.855 1.112 1.927l8.03.518"/><path fill="#fff" stroke="#e5e5e5" stroke-width="3" d="m13.837 45.647c5.04 5.734 12.429 9.353 20.663 9.353 15.188 0 27.5-12.312 27.5-27.5 0-15.188-12.312-27.5-27.5-27.5-15.188 0-27.5 12.312-27.5 27.5 0 2.928.458 5.749 1.305 8.396l-3.613 7.306c-.489.989.007 1.855 1.112 1.927l8.03.518" stroke-linecap="round" stroke-linejoin="round"/></g><g transform="translate(20.274 207.34)"><g transform="translate(4)"><use stroke="#b5a7dd" stroke-width="4" mask="url(#1)" xlink:href="#0"/><rect width="1" height="5" x="3" y="1" fill="#b5a7dd" rx=".5"/></g><g transform="translate(0 19)"><use fill="#fff" stroke="#6b4fbb" stroke-width="4" mask="url(#3)" xlink:href="#2"/><g fill="#6b4fbb"><rect width="1" height="5" x="6" y="11" rx=".5"/><rect width="1" height="5" x="8" y="11" rx=".5"/></g><use fill="#fff" stroke="#6b4fbb" stroke-width="4" mask="url(#5)" xlink:href="#4"/></g></g></g></svg>
\ No newline at end of file
......@@ -20,6 +20,7 @@ const Api = {
commitPath: '/api/:version/projects/:id/repository/commits',
branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch',
createBranchPath: '/api/:version/projects/:id/repository/branches',
geoNodesPath: '/api/:version/geo_nodes',
group(groupId, callback) {
const url = Api.buildUrl(Api.groupPath)
......
......@@ -50,7 +50,6 @@ import UserCallout from './user_callout';
import ShortcutsWiki from './shortcuts_wiki';
import Pipelines from './pipelines';
import BlobViewer from './blob/viewer/index';
import GeoNodes from './geo_nodes';
import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select';
import UsersSelect from './users_select';
import RefSelectDropdown from './ref_select_dropdown';
......@@ -710,7 +709,6 @@ import initLDAPGroupsSelect from 'ee/ldap_groups_select'; // eslint-disable-line
new AbuseReports();
break;
case 'geo_nodes':
new GeoNodes($('.geo-nodes'));
import(/* webpackChunkName: 'geo_node_form' */ './geo/geo_node_form')
.then(geoNodeForm => geoNodeForm.default($('.js-geo-node-form')))
.catch(() => {});
......
/* eslint-disable no-new*/
import axios from 'axios';
import SmartInterval from '~/smart_interval';
import { s__ } from '~/locale';
import { parseSeconds, stringifyTime } from './lib/utils/pretty_time';
import { formatDate, timeIntervalInWords } from './lib/utils/datetime_utility';
import timeago from './vue_shared/mixins/timeago';
const healthyClass = 'geo-node-healthy';
const unhealthyClass = 'geo-node-unhealthy';
const unknownClass = 'geo-node-unknown';
const healthyIcon = 'fa-check';
const unhealthyIcon = 'fa-times';
const unknownIcon = 'fa-times';
const notAvailable = 'Not Available';
const versionMismatch = 'Does not match the primary node version';
const nodeMismatchClass = 'geo-node-mismatch';
const storageMismatch = 'Does not match the primary storage configuration';
class GeoNodeStatus {
constructor(el) {
this.$el = $(el);
this.$icon = $('.js-geo-node-icon', this.$el);
this.$loadingIcon = $('.js-geo-node-loading', this.$el);
this.$dbReplicationLag = $('.js-db-replication-lag', this.$status);
this.$healthStatus = $('.js-health-status', this.$el);
this.$status = $('.js-geo-node-status', this.$el);
this.$repositories = $('.js-repositories', this.$status);
this.$wikis = $('.js-wikis', this.$status);
this.$lfsObjects = $('.js-lfs-objects', this.$status);
this.$attachments = $('.js-attachments', this.$status);
this.$syncSettings = $('.js-sync-settings', this.$status);
this.$lastEventSeen = $('.js-last-event-seen', this.$status);
this.$lastCursorEvent = $('.js-last-cursor-event', this.$status);
this.$health = $('.js-health-message', this.$status.parent());
this.$version = $('.js-gitlab-version', this.$status);
this.$secondaryVersion = $('.js-secondary-version', this.$status);
this.$secondaryStorage = $('.js-secondary-storage-shards', this.$status);
this.endpoint = this.$el.data('status-url');
this.$advancedStatus = $('.js-advanced-geo-node-status-toggler', this.$status.parent());
this.$advancedStatus.on('click', GeoNodeStatus.toggleShowAdvancedStatus.bind(this));
this.primaryVersion = $('.js-primary-version').text();
this.primaryRevision = $('.js-primary-revision').text().replace(/\W/g, '');
this.statusInterval = new SmartInterval({
callback: this.getStatus.bind(this),
startingInterval: 30000,
maxInterval: 120000,
hiddenInterval: 240000,
incrementByFactorOf: 15000,
immediateExecution: true,
});
}
static toggleShowAdvancedStatus(e) {
const $element = $(e.currentTarget);
const $advancedStatusItems = this.$status.find('.js-advanced-status');
$element.find('.js-advance-toggle')
.html(gl.utils.spriteIcon($advancedStatusItems.is(':hidden') ? 'angle-up' : 'angle-down', 's16'));
$advancedStatusItems.toggleClass('hidden');
}
static getSyncStatistics({ syncedCount, failedCount, totalCount }) {
const syncedPercent = Math.ceil((syncedCount / totalCount) * 100);
const failedPercent = Math.ceil((failedCount / totalCount) * 100);
const waitingPercent = 100 - syncedPercent - failedPercent;
return {
syncedPercent,
waitingPercent,
failedPercent,
syncedCount,
failedCount,
waitingCount: totalCount - syncedCount - failedCount,
};
}
static renderSyncGraph($itemEl, syncStats) {
const graphItems = [
{
itemSel: '.js-synced',
itemTooltip: s__('GeoNodeSyncStatus|Synced'),
itemCount: syncStats.syncedCount,
itemPercent: syncStats.syncedPercent,
},
{
itemSel: '.js-waiting',
itemTooltip: s__('GeoNodeSyncStatus|Out of sync'),
itemCount: syncStats.waitingCount,
itemPercent: syncStats.waitingPercent,
},
{
itemSel: '.js-failed',
itemTooltip: s__('GeoNodeSyncStatus|Failed'),
itemCount: syncStats.failedCount,
itemPercent: syncStats.failedPercent,
},
];
$itemEl.find('.js-stats-unavailable')
.toggleClass('hidden',
!!graphItems[0].itemCount ||
!!graphItems[1].itemCount ||
!!graphItems[2].itemCount);
graphItems.forEach((item) => {
$itemEl.find(item.itemSel)
.toggleClass('has-value has-tooltip', !!item.itemCount)
.attr('data-original-title', `${item.itemTooltip}: ${item.itemCount}`)
.text(`${item.itemPercent}%` || '')
.css('width', `${item.itemPercent}%`);
});
}
static renderEventStats($eventEl, eventId, eventTimestamp) {
const $eventTimestampEl = $eventEl.find('.js-event-timestamp');
let eventDate = notAvailable;
if (eventTimestamp && eventTimestamp > 0) {
eventDate = formatDate(new Date(eventTimestamp * 1000));
}
if (eventId) {
$eventEl.find('.js-event-id').text(eventId);
$eventTimestampEl
.attr('title', eventDate)
.text(`(${timeago.methods.timeFormated(eventDate)})`);
}
}
static renderSyncSettings($syncSettings, namespaces, eventStats) {
const { lastEventId, lastEventTimestamp, cursorEventId, cursorEventTimestamp } = eventStats;
const $syncStatusIcon = $syncSettings.find('.js-sync-status-icon');
const DIFFS = {
FIVE_MINS: 300,
HOUR: 3600,
};
let eventDateTime;
let cursorDateTime;
$syncSettings.find('.js-sync-type')
.text(namespaces.length > 0 ? 'Selective' : 'Full');
if (lastEventTimestamp && lastEventTimestamp > 0) {
eventDateTime = new Date(lastEventTimestamp * 1000);
}
if (cursorEventTimestamp && cursorEventTimestamp > 0) {
cursorDateTime = new Date(cursorEventTimestamp * 1000);
}
const timeDiffInSeconds = (cursorDateTime - eventDateTime) / 1000;
if (timeDiffInSeconds <= DIFFS.FIVE_MINS) {
// Lag is under 5 mins
$syncStatusIcon.html(gl.utils.spriteIcon('retry', 's16'));
} else if (timeDiffInSeconds > DIFFS.FIVE_MINS &&
timeDiffInSeconds <= DIFFS.HOUR) {
// Lag is between 5 mins to an hour
$syncStatusIcon.html(gl.utils.spriteIcon('warning', 's16'));
$syncSettings.attr('data-original-title', s__('GeoNodeSyncStatus|Node is slow, overloaded, or it just recovered after an outage.'));
} else {
// Lag is over an hour
$syncSettings.find('.js-sync-status').addClass('sync-status-failure');
$syncStatusIcon.html(gl.utils.spriteIcon('status_failed', 's16'));
$syncSettings.attr('data-original-title', s__('GeoNodeSyncStatus|Node is failing or broken.'));
}
const timeAgoStr = timeIntervalInWords(timeDiffInSeconds);
const pendingEvents = lastEventId - cursorEventId;
$syncSettings
.find('.js-sync-status-timestamp')
.text(`${timeAgoStr} (${pendingEvents} events)`);
}
getStatus() {
return axios.get(this.endpoint)
.then((response) => {
this.handleStatus(response.data);
return response;
})
.catch((err) => {
this.handleError(err);
});
}
handleStatus(status) {
this.setStatusIcon(status.healthy);
this.setHealthStatus({
healthy: status.healthy,
healthStatus: status.health_status,
healthMessage: status.health,
});
this.$version.text(status.version);
// Replication lag can be nil if the secondary isn't actually streaming
if (status.db_replication_lag_seconds !== null && status.db_replication_lag_seconds >= 0) {
const parsedTime = parseSeconds(status.db_replication_lag_seconds, {
hoursPerDay: 24,
daysPerWeek: 7,
});
this.$dbReplicationLag.text(stringifyTime(parsedTime));
} else {
this.$dbReplicationLag.text('UNKNOWN');
}
if (!this.primaryVersion || (this.primaryVersion === status.version
&& this.primaryRevision === status.revision)) {
this.$secondaryVersion.removeClass(`${nodeMismatchClass}`);
this.$secondaryVersion.text(`${status.version} (${status.revision})`);
} else {
this.$secondaryVersion.addClass(`${nodeMismatchClass}`);
this.$secondaryVersion.text(`${status.version} (${status.revision}) - ${versionMismatch}`);
}
if (status.storage_shards_match == null) {
this.$secondaryStorage.text('UNKNOWN');
} else if (status.storage_shards_match) {
this.$secondaryStorage.removeClass(`${nodeMismatchClass}`);
this.$secondaryStorage.text('OK');
} else {
this.$secondaryStorage.addClass(`${nodeMismatchClass}`);
this.$secondaryStorage.text(storageMismatch);
}
if (status.repositories_count > 0) {
const repositoriesStats = GeoNodeStatus.getSyncStatistics({
syncedCount: status.repositories_synced_count,
failedCount: status.repositories_failed_count,
totalCount: status.repositories_count,
});
GeoNodeStatus.renderSyncGraph(this.$repositories, repositoriesStats);
}
if (status.wikis_count > 0) {
const wikisStats = GeoNodeStatus.getSyncStatistics({
syncedCount: status.wikis_synced_count,
failedCount: status.wikis_failed_count,
totalCount: status.wikis_count,
});
GeoNodeStatus.renderSyncGraph(this.$wikis, wikisStats);
}
if (status.lfs_objects_count > 0) {
const lfsObjectsStats = GeoNodeStatus.getSyncStatistics({
syncedCount: status.lfs_objects_synced_count,
failedCount: status.lfs_objects_failed_count,
totalCount: status.lfs_objects_count,
});
GeoNodeStatus.renderSyncGraph(this.$lfsObjects, lfsObjectsStats);
}
if (status.attachments_count > 0) {
const attachmentsStats = GeoNodeStatus.getSyncStatistics({
syncedCount: status.attachments_synced_count,
failedCount: status.attachments_failed_count,
totalCount: status.attachments_count,
});
GeoNodeStatus.renderSyncGraph(this.$attachments, attachmentsStats);
}
if (status.namespaces) {
GeoNodeStatus.renderSyncSettings(
this.$syncSettings,
status.namespaces, {
lastEventId: status.last_event_id,
lastEventTimestamp: status.last_event_timestamp,
cursorEventId: status.cursor_last_event_id,
cursorEventTimestamp: status.cursor_last_event_timestamp,
});
}
GeoNodeStatus.renderEventStats(
this.$lastEventSeen,
status.last_event_id,
status.last_event_timestamp);
GeoNodeStatus.renderEventStats(
this.$lastCursorEvent,
status.cursor_last_event_id,
status.cursor_last_event_timestamp);
this.$status.removeClass('hidden');
}
handleError(err) {
this.setStatusIcon(false);
this.setHealthStatus(false);
this.$health.text(err);
this.$health.removeClass('hidden');
this.$status.removeClass('hidden');
}
setStatusIcon(healthy) {
this.$loadingIcon.hide();
this.$icon.removeClass(`${unknownClass} ${unknownIcon}`);
if (healthy) {
this.$icon.removeClass(`${unhealthyClass} ${unhealthyIcon}`)
.addClass(`${healthyClass} ${healthyIcon}`)
.attr('title', 'Healthy');
} else {
this.$icon.removeClass(`${healthyClass} ${healthyIcon}`)
.addClass(`${unhealthyClass} ${unhealthyIcon}`)
.attr('title', 'Unhealthy');
}
}
setHealthStatus({ healthy, healthStatus, healthMessage }) {
if (healthy) {
this.$healthStatus.removeClass(unhealthyClass)
.addClass(healthyClass)
.text(healthMessage);
this.$health.text('');
this.$health.addClass('hidden');
} else {
this.$healthStatus.removeClass(healthyClass)
.addClass(unhealthyClass)
.text(healthStatus);
const strippedData = $('<div>').html(`${healthMessage}`).text();
this.$health.text(strippedData);
this.$health.removeClass('hidden');
}
}
}
class GeoNodes {
constructor(container) {
this.$container = $(container);
this.pollForSecondaryNodeStatus();
}
pollForSecondaryNodeStatus() {
$('.js-geo-secondary-node', this.$container).each((i, el) => {
new GeoNodeStatus(el);
});
}
}
export default GeoNodes;
<script>
import tooltip from '~/vue_shared/directives/tooltip';
export default {
props: {
cssClass: {
type: String,
required: false,
default: '',
},
successLabel: {
type: String,
required: true,
},
failureLabel: {
type: String,
required: true,
},
neutralLabel: {
type: String,
required: true,
},
successCount: {
type: Number,
required: true,
},
failureCount: {
type: Number,
required: true,
},
totalCount: {
type: Number,
required: true,
},
},
directives: {
tooltip,
},
computed: {
successPercent() {
return this.getPercent(this.successCount);
},
successBarStyle() {
return this.barStyle(this.successPercent);
},
successTooltip() {
return this.getTooltip(this.successLabel, this.successCount);
},
failurePercent() {
return this.getPercent(this.failureCount);
},
failureBarStyle() {
return this.barStyle(this.failurePercent);
},
failureTooltip() {
return this.getTooltip(this.failureLabel, this.failureCount);
},
neutralPercent() {
return 100 - this.successPercent - this.failurePercent;
},
neutralBarStyle() {
return this.barStyle(this.neutralPercent);
},
neutralTooltip() {
const neutralCount = this.totalCount - this.successCount - this.failureCount;
return this.getTooltip(this.neutralLabel, neutralCount);
},
},
methods: {
getPercent(count) {
return Math.ceil((count / this.totalCount) * 100);
},
barStyle(percent) {
return `width: ${percent}%;`;
},
getTooltip(label, count) {
return `${label}: ${count}`;
},
},
};
</script>
<template>
<div
class="stacked-progress-bar"
:class="cssClass"
>
<span
v-if="!totalCount"
class="status-unavailable"
>
{{__("Not available")}}
</span>
<span
v-tooltip
v-if="successPercent"
class="status-green"
data-placement="bottom"
:title="successTooltip"
:style="successBarStyle"
>
{{successPercent}}%
</span>
<span
v-tooltip
v-if="neutralPercent"
class="status-neutral"
data-placement="bottom"
:title="neutralTooltip"
:style="neutralBarStyle"
>
{{neutralPercent}}%
</span>
<span
v-tooltip
v-if="failurePercent"
class="status-red"
data-placement="bottom"
:title="failureTooltip"
:style="failureBarStyle"
>
{{failurePercent}}%
</span>
</div>
</template>
......@@ -461,11 +461,13 @@ img.emoji {
.prepend-left-4 { margin-left: 4px; }
.prepend-left-5 { margin-left: 5px; }
.prepend-left-10 { margin-left: 10px; }
.prepend-left-15 { margin-left: 15px; }
.prepend-left-default { margin-left: $gl-padding; }
.prepend-left-20 { margin-left: 20px; }
.append-right-5 { margin-right: 5px; }
.append-right-8 { margin-right: 8px; }
.append-right-10 { margin-right: 10px; }
.append-right-15 { margin-right: 15px; }
.append-right-default { margin-right: $gl-padding; }
.append-right-20 { margin-right: 20px; }
.append-bottom-0 { margin-bottom: 0; }
......
.stacked-progress-bar {
display: flex;
height: 20px;
width: 350px;
height: 16px;
border-radius: 10px;
overflow: hidden;
background-color: $theme-gray-100;
......@@ -11,15 +10,12 @@
.status-neutral,
.status-red, {
height: 100%;
min-width: 25px;
padding: 0 5px;
font-size: $tooltip-font-size;
font-weight: normal;
color: $white-light;
line-height: 20px;
&.has-value {
min-width: 25px;
padding: 0 5px;
}
line-height: 16px;
&:hover {
cursor: pointer;
......
......@@ -11,55 +11,44 @@
.page-subtitle {
margin-bottom: 24px;
}
}
.geo-node-status {
td {
vertical-align: top;
}
.help-block {
width: 135px;
text-align: right;
}
.node-info {
font-weight: $gl-font-weight-bold;
}
.event-timestamp {
.health-message {
padding: 4px 8px 1px;
background-color: $red-100;
color: $red-500;
border-radius: $border-radius-default;
font-weight: normal;
color: $theme-gray-800;
}
}
.sync-status {
font-weight: normal;
svg {
vertical-align: middle;
}
.well-list.geo-nodes {
li {
position: relative;
.sync-status-icon svg,
.sync-status-timestamp {
fill: $theme-gray-700;
color: $theme-gray-700;
&:hover {
background: $white-light;
}
&.sync-status-failure {
.sync-status-icon svg,
.sync-status-timestamp {
fill: $red-700;
color: $red-700;
}
&.node-disabled,
&.node-disabled:hover {
background-color: $gray-lightest;
}
}
}
.advanced-geo-node-status-toggler {
display: block;
.node-badge {
color: $white-light;
display: inline-block;
margin-left: 5px;
padding: 0 5px;
border-radius: 3px;
.show-advance-chevron {
margin-top: 2px;
&.primary-node {
background-color: $blue-300;
}
&.current-node {
background-color: $green-400;
}
}
......@@ -71,6 +60,10 @@
color: $gl-danger;
}
.geo-node-offline {
color: $theme-gray-950;
}
.geo-node-disabled {
color: $gray-darkest;
}
......@@ -79,84 +72,102 @@
color: $gray-darkest;
}
.well-list.geo-nodes {
li {
position: relative;
.geo-nodes {
.node-url-warning {
fill: $gl-warning;
}
}
&:hover {
background: $white-light;
.node-details-list {
.node-detail-item {
margin-top: 14px;
&:first-child {
margin-top: 0;
}
&.node-disabled,
&.node-disabled:hover {
background-color: $gray-lightest;
.node-detail-title,
.node-detail-value {
padding-left: $gl-col-padding;
padding-right: $gl-col-padding;
}
.node-detail-title {
color: $theme-gray-700;
}
}
.advanced-geo-node-status-container {
.btn-link {
padding-left: 0;
padding-right: 0;
border-left: 0;
border-right: 0;
.node-detail-value {
margin-top: 4px;
}
.fa {
margin-left: 3px;
.node-detail-value-bold {
font-weight: $gl-font-weight-bold;
}
.node-detail-value-error {
color: $gl-danger;
}
}
.node-info {
color: $gl-text-color;
}
.btn-show-advanced {
padding-left: 0;
border: 0;
}
.geo-health {
display: inline-block;
margin-top: 5px;
white-space: pre-wrap;
}
.node-health-status {
display: inline-flex;
.geo-nodes {
.health-message {
padding: 1px 8px;
background-color: $red-100;
color: $red-500;
border-radius: $border-radius-default;
font-weight: 500;
.status-text {
line-height: 18px;
}
}
}
.geo-node-mismatch {
color: $gl-danger;
}
.node-sync-settings {
display: inline-flex;
cursor: pointer;
.node-badge {
color: $white-light;
display: inline-block;
margin-left: 5px;
padding: 0 5px;
border-radius: 3px;
.sync-status-icon {
margin-top: 2px;
fill: $theme-gray-700;
}
}
&.primary-node {
background-color: $blue-300;
.sync-status-event-info,
.event-status-timestamp {
color: $theme-gray-700;
}
&.current-node {
background-color: $green-400;
.event-status-timestamp {
cursor: pointer;
}
}
}
.node-actions {
margin-top: 10px;
.geo-node-actions {
display: inline-flex;
justify-content: center;
float: right;
.node-action-container {
margin: 0 5px;
&:last-child {
margin-right: $gl-padding;
}
}
@media (max-width: $screen-sm-max) {
display: block;
width: 100%;
@media (min-width: $screen-md-min) {
position: absolute;
right: 15px;
top: 0;
.node-action-container {
width: 100%;
margin: 0;
margin-top: 10px;
padding: 0 10px;
}
.btn:not(:first-of-type) {
margin-left: 10px;
.btn-node-action {
width: 100%;
}
}
}
---
title: Geo admin screen enhancements
merge_request: 3902
author:
type: changed
......@@ -44,6 +44,7 @@ var config = {
epic_show: 'ee/epics/epic_show/epic_show_bundle.js',
new_epic: 'ee/epics/new_epic/new_epic_bundle.js',
filtered_search: './filtered_search/filtered_search_bundle.js',
geo_nodes: 'ee/geo_nodes',
graphs: './graphs/graphs_bundle.js',
graphs_charts: './graphs/graphs_charts.js',
graphs_show: './graphs/graphs_show.js',
......
<script>
import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import SmartInterval from '~/smart_interval';
import eventHub from '../event_hub';
import geoNodesList from './geo_nodes_list.vue';
export default {
components: {
loadingIcon,
geoNodesList,
},
props: {
store: {
type: Object,
required: true,
},
service: {
type: Object,
required: true,
},
nodeActionsAllowed: {
type: Boolean,
required: true,
},
nodeEditAllowed: {
type: Boolean,
required: true,
},
},
data() {
return {
isLoading: true,
hasError: false,
errorMessage: '',
};
},
computed: {
nodes() {
return this.store.getNodes();
},
},
methods: {
fetchGeoNodes() {
this.hasError = false;
this.service.getGeoNodes()
.then(res => res.data)
.then((nodes) => {
this.store.setNodes(nodes);
this.isLoading = false;
})
.catch((err) => {
this.hasError = true;
this.errorMessage = err;
});
},
fetchNodeDetails(nodeId) {
return this.service.getGeoNodeDetails(nodeId)
.then(res => res.data)
.then((nodeDetails) => {
const primaryNodeVersion = this.store.getPrimaryNodeVersion();
const updatedNodeDetails = Object.assign(nodeDetails, {
primaryVersion: primaryNodeVersion.version,
primaryRevision: primaryNodeVersion.revision,
});
this.store.setNodeDetails(nodeId, updatedNodeDetails);
eventHub.$emit('nodeDetailsLoaded', this.store.getNodeDetails(nodeId));
})
.catch((err) => {
this.hasError = true;
this.errorMessage = err;
});
},
initNodeDetailsPolling(nodeId) {
this.nodePollingInterval = new SmartInterval({
callback: this.fetchNodeDetails.bind(this, nodeId),
startingInterval: 30000,
maxInterval: 120000,
hiddenInterval: 240000,
incrementByFactorOf: 15000,
immediateExecution: true,
});
},
},
created() {
eventHub.$on('pollNodeDetails', this.initNodeDetailsPolling);
},
mounted() {
this.fetchGeoNodes();
},
beforeDestroy() {
eventHub.$off('pollNodeDetails', this.initNodeDetailsPolling);
if (this.nodePollingInterval) {
this.nodePollingInterval.stopTimer();
}
},
};
</script>
<template>
<div class="panel panel-default">
<div class="panel-heading">
Geo nodes ({{nodes.length}})
</div>
<loading-icon
class="loading-animation prepend-top-20 append-bottom-20"
size="2"
v-if="isLoading"
:label="s__('GeoNodes|Loading nodes')"
/>
<geo-nodes-list
v-if="!isLoading"
:nodes="nodes"
:node-actions-allowed="nodeActionsAllowed"
:node-edit-allowed="nodeEditAllowed"
/>
<p
class="health-message prepend-left-15 append-right-15"
v-if="hasError"
>
{{errorMessage}}
</p>
</div>
</template>
<script>
import { __, s__ } from '~/locale';
import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import { NODE_ACTION_BASE_PATH, NODE_ACTIONS } from '../constants';
export default {
props: {
node: {
type: Object,
required: true,
},
nodeEditAllowed: {
type: Boolean,
required: true,
},
nodeMissingOauth: {
type: Boolean,
required: true,
},
},
data() {
return {
isNodeToggleInProgress: false,
};
},
components: {
loadingIcon,
},
computed: {
isToggleAllowed() {
return !this.node.primary && this.nodeEditAllowed;
},
nodeToggleLabel() {
return this.node.enabled ? __('Disable') : __('Enable');
},
nodeDisableMessage() {
return this.node.enabled ? s__('GeoNodes|Disabling a node stops the sync process. Are you sure?') : '';
},
nodePath() {
return `${NODE_ACTION_BASE_PATH}${this.node.id}`;
},
nodeRepairAuthPath() {
return `${this.nodePath}${NODE_ACTIONS.REPAIR}`;
},
nodeTogglePath() {
return `${this.nodePath}${NODE_ACTIONS.TOGGLE}`;
},
nodeEditPath() {
return `${this.nodePath}${NODE_ACTIONS.EDIT}`;
},
},
};
</script>
<template>
<div class="geo-node-actions">
<div
v-if="nodeMissingOauth"
class="node-action-container"
>
<a
class="btn btn-default btn-sm btn-node-action"
data-method="post"
:href="nodeRepairAuthPath"
>
{{s__('Repair authentication')}}
</a>
</div>
<div
v-if="isToggleAllowed"
class="node-action-container"
>
<a
class="btn btn-sm btn-node-action"
data-method="post"
:href="nodeTogglePath"
:data-confirm="nodeDisableMessage"
:class="{ 'btn-warning': node.enabled, 'btn-success': !node.enabled }"
>
{{nodeToggleLabel}}
</a>
</div>
<div
v-if="nodeEditAllowed"
class="node-action-container"
>
<a
class="btn btn-sm btn-node-action"
:href="nodeEditPath"
>
{{__('Edit')}}
</a>
</div>
<div class="node-action-container">
<a
class="btn btn-sm btn-node-action btn-danger"
data-method="delete"
:href="nodePath"
>
{{__('Remove')}}
</a>
</div>
</div>
</template>
<script>
import { s__ } from '~/locale';
import stackedProgressBar from '~/vue_shared/components/stacked_progress_bar.vue';
import { VALUE_TYPE, CUSTOM_TYPE } from '../constants';
import geoNodeHealthStatus from './geo_node_health_status.vue';
import geoNodeSyncSettings from './geo_node_sync_settings.vue';
import geoNodeEventStatus from './geo_node_event_status.vue';
export default {
props: {
itemTitle: {
type: String,
required: true,
},
cssClass: {
type: String,
required: false,
default: '',
},
itemValue: {
type: [Object, String, Number],
required: true,
},
successLabel: {
type: String,
required: false,
default: s__('GeoNodes|Synced'),
},
failureLabel: {
type: String,
required: false,
default: s__('GeoNodes|Failed'),
},
neutralLabel: {
type: String,
required: false,
default: s__('GeoNodes|Out of sync'),
},
itemValueType: {
type: String,
required: true,
},
customType: {
type: String,
required: false,
default: '',
},
},
components: {
stackedProgressBar,
geoNodeHealthStatus,
geoNodeSyncSettings,
geoNodeEventStatus,
},
computed: {
isValueTypePlain() {
return this.itemValueType === VALUE_TYPE.PLAIN;
},
isValueTypeGraph() {
return this.itemValueType === VALUE_TYPE.GRAPH;
},
isValueTypeCustom() {
return this.itemValueType === VALUE_TYPE.CUSTOM;
},
isCustomTypeStatus() {
return this.customType === CUSTOM_TYPE.STATUS;
},
isCustomTypeSync() {
return this.customType === CUSTOM_TYPE.SYNC;
},
},
};
</script>
<template>
<li class="row node-detail-item">
<div class="node-detail-title">
{{itemTitle}}
</div>
<div
v-if="isValueTypePlain"
class="node-detail-value"
:class="cssClass"
>
{{itemValue}}
</div>
<div
v-if="isValueTypeGraph"
class="node-detail-value"
>
<stacked-progress-bar
:success-label="successLabel"
:failure-label="failureLabel"
:neutral-label="neutralLabel"
:success-count="itemValue.successCount"
:failure-count="itemValue.failureCount"
:total-count="itemValue.totalCount"
/>
</div>
<template v-if="isValueTypeCustom">
<geo-node-health-status
v-if="isCustomTypeStatus"
:status="itemValue"
/>
<geo-node-sync-settings
v-else-if="isCustomTypeSync"
:namespaces="itemValue.namespaces"
:last-event="itemValue.lastEvent"
:cursor-last-event="itemValue.cursorLastEvent"
/>
<geo-node-event-status
v-else
:event-id="itemValue.eventId"
:event-time-stamp="itemValue.eventTimeStamp"
/>
</template>
</li>
</template>
<script>
import { s__, __ } from '~/locale';
import { parseSeconds, stringifyTime } from '~/lib/utils/pretty_time';
import { bytesToMiB } from '~/lib/utils/number_utils';
import icon from '~/vue_shared/components/icon.vue';
import { VALUE_TYPE, CUSTOM_TYPE } from '../constants';
import geoNodeDetailItem from './geo_node_detail_item.vue';
export default {
props: {
node: {
type: Object,
required: true,
},
nodeDetails: {
type: Object,
required: true,
},
},
data() {
return {
showAdvanceItems: false,
errorMessage: '',
nodeDetailItems: [
{
itemTitle: s__('GeoNodes|Storage config:'),
itemValue: this.storageShardsStatus(),
itemValueType: VALUE_TYPE.PLAIN,
cssClass: this.plainValueCssClass(!this.nodeDetails.storageShardsMatch),
},
{
itemTitle: s__('GeoNodes|Health status:'),
itemValue: this.nodeHealthStatus(),
itemValueType: VALUE_TYPE.CUSTOM,
customType: CUSTOM_TYPE.STATUS,
},
{
itemTitle: s__('GeoNodes|Repositories:'),
itemValue: this.nodeDetails.repositories,
itemValueType: VALUE_TYPE.GRAPH,
},
{
itemTitle: s__('GeoNodes|Wikis:'),
itemValue: this.nodeDetails.wikis,
itemValueType: VALUE_TYPE.GRAPH,
},
{
itemTitle: s__('GeoNodes|LFS objects:'),
itemValue: this.nodeDetails.lfs,
itemValueType: VALUE_TYPE.GRAPH,
},
{
itemTitle: s__('GeoNodes|Attachments:'),
itemValue: this.nodeDetails.attachments,
itemValueType: VALUE_TYPE.GRAPH,
},
{
itemTitle: s__('GeoNodes|Sync settings:'),
itemValue: this.syncSettings(),
itemValueType: VALUE_TYPE.CUSTOM,
customType: CUSTOM_TYPE.SYNC,
},
],
};
},
components: {
icon,
geoNodeDetailItem,
},
computed: {
hasError() {
if (!this.nodeDetails.healthy) {
this.errorMessage = this.nodeDetails.health;
}
return !this.nodeDetails.healthy;
},
hasVersionMismatch() {
if (this.nodeDetails.version !== this.nodeDetails.primaryVersion ||
this.nodeDetails.revision !== this.nodeDetails.primaryRevision) {
this.errorMessage = s__('GeoNodes|GitLab version does not match the primary node version');
return true;
}
return false;
},
versionCssClass() {
return this.plainValueCssClass(this.hasVersionMismatch);
},
advanceButtonIcon() {
return this.showAdvanceItems ? 'angle-up' : 'angle-down';
},
nodeVersion() {
return `${this.nodeDetails.version} (${this.nodeDetails.revision})`;
},
replicationSlotWAL() {
return `${bytesToMiB(this.nodeDetails.replicationSlotWAL)} MB`;
},
dbReplicationLag() {
// Replication lag can be nil if the secondary isn't actually streaming
if (this.nodeDetails.dbReplicationLag !== null &&
this.nodeDetails.dbReplicationLag >= 0) {
const parsedTime = parseSeconds(this.nodeDetails.dbReplicationLag, {
hoursPerDay: 24,
daysPerWeek: 7,
});
return stringifyTime(parsedTime);
}
return 'Unknown';
},
lastEventStatus() {
return {
eventId: this.nodeDetails.lastEvent.id,
eventTimeStamp: this.nodeDetails.lastEvent.timeStamp,
};
},
cursorLastEventStatus() {
return {
eventId: this.nodeDetails.cursorLastEvent.id,
eventTimeStamp: this.nodeDetails.cursorLastEvent.timeStamp,
};
},
valueType() {
return VALUE_TYPE;
},
customType() {
return CUSTOM_TYPE;
},
},
methods: {
nodeHealthStatus() {
return this.nodeDetails.healthy ? this.nodeDetails.health : this.nodeDetails.healthStatus;
},
storageShardsStatus() {
if (this.nodeDetails.storageShardsMatch === null) {
return __('Unknown');
}
return this.nodeDetails.storageShardsMatch ? __('OK') : s__('GeoNodes|Does not match the primary storage configuration');
},
plainValueCssClass(value) {
const cssClass = 'node-detail-value-bold';
return value ? `${cssClass} node-detail-value-error` : cssClass;
},
syncSettings() {
return {
namespaces: this.nodeDetails.namespaces,
lastEvent: this.nodeDetails.lastEvent,
cursorLastEvent: this.nodeDetails.cursorLastEvent,
};
},
onClickShowAdvance() {
this.showAdvanceItems = !this.showAdvanceItems;
},
},
};
</script>
<template>
<div class="row">
<ul class="col-md-8 list-unstyled node-details-list">
<geo-node-detail-item
:item-title="s__('GeoNodes|GitLab version:')"
:css-class="versionCssClass"
:item-value="nodeVersion"
:item-value-type="valueType.PLAIN"
/>
<template v-if="!node.primary">
<geo-node-detail-item
v-for="(nodeDetailItem, index) in nodeDetailItems"
:key="index"
:css-class="nodeDetailItem.cssClass"
:item-title="nodeDetailItem.itemTitle"
:item-value="nodeDetailItem.itemValue"
:item-value-type="nodeDetailItem.itemValueType"
:custom-type="nodeDetailItem.customType"
/>
</template>
<li class="prepend-top-5 node-detail-item">
<button
class="btn-link btn-show-advanced"
type="button"
@click="onClickShowAdvance"
>
<span>{{__('Advanced')}}</span>
<icon
:size=12
:name="advanceButtonIcon"
/>
</button>
</li>
<template v-if="showAdvanceItems">
<template v-if="node.primary">
<geo-node-detail-item
:item-title="s__('GeoNodes|Replication slots:')"
:success-label="s__('GeoNodes|Used slots')"
:neutral-label="s__('GeoNodes|Unused slots')"
:item-value="nodeDetails.replicationSlots"
:item-value-type="valueType.GRAPH"
/>
<geo-node-detail-item
v-if="nodeDetails.replicationSlots.totalCount"
css-class="node-detail-value-bold"
:item-title="s__('GeoNodes|Replication slot WAL:')"
:item-value="replicationSlotWAL"
:item-value-type="valueType.PLAIN"
/>
</template>
<template v-else>
<geo-node-detail-item
css-class="node-detail-value-bold"
:item-title="s__('GeoNodes|Database replication lag:')"
:item-value="dbReplicationLag"
:item-value-type="valueType.PLAIN"
/>
<geo-node-detail-item
:item-title="s__('GeoNodes|Last event ID seen from primary:')"
:item-value="lastEventStatus"
:item-value-type="valueType.CUSTOM"
:custom-type="customType.EVENT"
/>
<geo-node-detail-item
:item-title="s__('GeoNodes|Last event ID processed by cursor:')"
:item-value="cursorLastEventStatus"
:item-value-type="valueType.CUSTOM"
:custom-type="customType.EVENT"
/>
</template>
</template>
</ul>
<div
v-if="hasError || hasVersionMismatch"
class="col-md-12 prepend-top-10"
>
<p class="health-message">
{{errorMessage}}
</p>
</div>
</div>
</template>
<script>
import { formatDate } from '~/lib/utils/datetime_utility';
import timeAgoMixin from '~/vue_shared/mixins/timeago';
import tooltip from '~/vue_shared/directives/tooltip';
export default {
props: {
eventId: {
type: Number,
required: true,
},
eventTimeStamp: {
type: Number,
required: true,
},
},
mixins: [
timeAgoMixin,
],
directives: {
tooltip,
},
computed: {
timeStamp() {
return new Date(this.eventTimeStamp * 1000);
},
timeStampString() {
return formatDate(this.timeStamp);
},
},
};
</script>
<template>
<div
class="node-detail-value"
>
<strong>
{{eventId}}
</strong>
<span
v-tooltip
class="event-status-timestamp"
data-placement="bottom"
:title="timeStampString"
>
({{timeFormated(timeStamp)}})
</span>
</div>
</template>
<script>
import icon from '~/vue_shared/components/icon.vue';
import { HEALTH_STATUS_ICON } from '../constants';
export default {
props: {
status: {
type: String,
required: true,
},
},
components: {
icon,
},
computed: {
healthCssClass() {
return `geo-node-${this.status.toLowerCase()}`;
},
statusIconName() {
return HEALTH_STATUS_ICON[this.status.toLowerCase()];
},
},
};
</script>
<template>
<div
class="node-detail-value node-health-status"
:class="healthCssClass"
>
<icon
:size="16"
:name="statusIconName"
/>
<span
class="status-text prepend-left-5"
>
{{status}}
</span>
</div>
</template>
<script>
import icon from '~/vue_shared/components/icon.vue';
import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import eventHub from '../event_hub';
import geoNodeActions from './geo_node_actions.vue';
import geoNodeDetails from './geo_node_details.vue';
export default {
props: {
node: {
type: Object,
required: true,
},
primaryNode: {
type: Boolean,
required: true,
},
nodeActionsAllowed: {
type: Boolean,
required: true,
},
nodeEditAllowed: {
type: Boolean,
required: true,
},
},
components: {
icon,
loadingIcon,
geoNodeActions,
geoNodeDetails,
},
directives: {
tooltip,
},
data() {
return {
isNodeDetailsLoading: true,
nodeHealthStatus: '',
nodeDetails: {},
};
},
computed: {
showInsecureUrlWarning() {
return this.node.url.startsWith('http://');
},
},
methods: {
handleNodeDetails(nodeDetails) {
if (this.node.id === nodeDetails.id) {
this.isNodeDetailsLoading = false;
this.nodeDetails = nodeDetails;
this.nodeHealthStatus = nodeDetails.health;
}
},
handleMounted() {
eventHub.$emit('pollNodeDetails', this.node.id);
},
},
created() {
eventHub.$on('nodeDetailsLoaded', this.handleNodeDetails);
},
mounted() {
this.handleMounted();
},
beforeDestroy() {
eventHub.$off('nodeDetailsLoaded', this.handleNodeDetails);
},
};
</script>
<template>
<li>
<div class="row">
<div class="col-md-8">
<div class="row">
<div class="col-md-8 clearfix">
<strong class="node-url inline pull-left">
{{node.url}}
</strong>
<loading-icon
v-if="isNodeDetailsLoading"
class="node-details-loading prepend-left-10 pull-left inline"
size=1
/>
<icon
v-tooltip
v-if="!isNodeDetailsLoading && showInsecureUrlWarning"
css-classes="prepend-left-10 pull-left node-url-warning"
name="warning"
data-container="body"
data-placement="bottom"
:title="s__('GeoNodes|You have configured Geo nodes using an insecure HTTP connection. We recommend the use of HTTPS.')"
:size="18"
/>
<span class="inline pull-left prepend-left-10">
<span
class="node-badge current-node"
v-if="node.current"
>
{{s__('Current node')}}
</span>
<span
class="node-badge primary-node"
v-if="node.primary"
>
{{s__('Primary')}}
</span>
</span>
</div>
</div>
</div>
<geo-node-actions
v-if="!isNodeDetailsLoading && nodeActionsAllowed"
:node="node"
:node-edit-allowed="nodeEditAllowed"
:node-missing-oauth="nodeDetails.missingOAuthApplication"
/>
</div>
<geo-node-details
v-if="!isNodeDetailsLoading"
:node="node"
:node-details="nodeDetails"
/>
</li>
</template>
<script>
import { s__ } from '~/locale';
import { timeIntervalInWords } from '~/lib/utils/datetime_utility';
import tooltip from '~/vue_shared/directives/tooltip';
import icon from '~/vue_shared/components/icon.vue';
import { TIME_DIFF } from '../constants';
export default {
props: {
namespaces: {
type: Array,
required: true,
},
lastEvent: {
type: Object,
required: true,
},
cursorLastEvent: {
type: Object,
required: true,
},
},
directives: {
tooltip,
},
components: {
icon,
},
computed: {
syncType() {
return this.namespaces.length > 0 ? s__('GeoNodes|Selective') : s__('GeoNodes|Full');
},
syncLagInSeconds() {
return this.lagInSeconds(this.lastEvent.timeStamp, this.cursorLastEvent.timeStamp);
},
syncStatusIcon() {
return this.statusIcon(this.syncLagInSeconds);
},
syncStatusEventInfo() {
return this.statusEventInfo(
this.lastEvent.id,
this.cursorLastEvent.id,
this.syncLagInSeconds,
);
},
syncStatusTooltip() {
return this.statusTooltip(this.syncLagInSeconds);
},
},
methods: {
lagInSeconds(lastEventTimeStamp, cursorLastEventTimeStamp) {
let eventDateTime;
let cursorDateTime;
if (lastEventTimeStamp && lastEventTimeStamp > 0) {
eventDateTime = new Date(lastEventTimeStamp * 1000);
}
if (cursorLastEventTimeStamp && cursorLastEventTimeStamp > 0) {
cursorDateTime = new Date(cursorLastEventTimeStamp * 1000);
}
return (cursorDateTime - eventDateTime) / 1000;
},
statusIcon(syncLag) {
if (syncLag <= TIME_DIFF.FIVE_MINS) {
return 'retry';
} else if (syncLag > TIME_DIFF.FIVE_MINS &&
syncLag <= TIME_DIFF.HOUR) {
return 'warning';
}
return 'status_failed';
},
statusEventInfo(lastEventId, cursorLastEventId, lagInSeconds) {
const timeAgoStr = timeIntervalInWords(lagInSeconds);
const pendingEvents = lastEventId - cursorLastEventId;
return `${timeAgoStr} (${pendingEvents} events)`;
},
statusTooltip(lagInSeconds) {
if (lagInSeconds <= TIME_DIFF.FIVE_MINS) {
return '';
} else if (lagInSeconds > TIME_DIFF.FIVE_MINS &&
lagInSeconds <= TIME_DIFF.HOUR) {
return s__('GeoNodeSyncStatus|Node is slow, overloaded, or it just recovered after an outage.');
}
return s__('GeoNodeSyncStatus|Node is failing or broken.');
},
},
};
</script>
<template>
<div
class="node-detail-value"
>
<span
v-tooltip
class="node-sync-settings inline"
data-placement="bottom"
:title="syncStatusTooltip"
>
<strong>{{syncType}}</strong>
<icon
name="retry"
css-classes="sync-status-icon prepend-left-5"
/>
<span
class="sync-status-event-info prepend-left-5"
>
{{syncStatusEventInfo}}
</span>
</span>
</div>
</template>
<script>
import geoNodeItem from './geo_node_item.vue';
export default {
components: {
geoNodeItem,
},
props: {
nodes: {
type: Array,
required: true,
},
nodeActionsAllowed: {
type: Boolean,
required: true,
},
nodeEditAllowed: {
type: Boolean,
required: true,
},
},
};
</script>
<template>
<ul class="well-list geo-nodes">
<geo-node-item
v-for="(node, index) in nodes"
:key="index"
:node="node"
:primary-node="node.primary"
:node-actions-allowed="nodeActionsAllowed"
:node-edit-allowed="nodeEditAllowed"
/>
</ul>
</template>
export const NODE_ACTION_BASE_PATH = '/admin/geo_nodes/';
export const NODE_ACTIONS = {
TOGGLE: '/toggle',
EDIT: '/edit',
REPAIR: '/reconfigure',
};
export const VALUE_TYPE = {
PLAIN: 'plain',
GRAPH: 'graph',
CUSTOM: 'custom',
};
export const CUSTOM_TYPE = {
SYNC: 'sync',
EVENT: 'event',
STATUS: 'status',
};
export const HEALTH_STATUS_ICON = {
healthy: 'status_success',
unhealthy: 'status_failed',
disabled: 'status_canceled',
unknown: 'status_warning',
offline: 'status_canceled',
};
export const TIME_DIFF = {
FIVE_MINS: 300,
HOUR: 3600,
};
import Vue from 'vue';
export default new Vue();
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
import { convertPermissionToBoolean } from '~/lib/utils/common_utils';
import GeoNodesStore from './store/geo_nodes_store';
import GeoNodesService from './service/geo_nodes_service';
import geoNodesApp from './components/app.vue';
Vue.use(Translate);
document.addEventListener('DOMContentLoaded', () => {
const el = document.getElementById('js-geo-nodes');
if (!el) {
return;
}
// eslint-disable-next-line no-new
new Vue({
el,
components: {
geoNodesApp,
},
data() {
const dataset = this.$options.el.dataset;
const nodeActionsAllowed = convertPermissionToBoolean(dataset.nodeActionsAllowed);
const nodeEditAllowed = convertPermissionToBoolean(dataset.nodeEditAllowed);
const store = new GeoNodesStore(dataset.primaryVersion, dataset.primaryRevision);
const service = new GeoNodesService(dataset.nodeDetailsPath);
return {
store,
service,
nodeActionsAllowed,
nodeEditAllowed,
};
},
render(createElement) {
return createElement('geo-nodes-app', {
props: {
store: this.store,
service: this.service,
nodeActionsAllowed: this.nodeActionsAllowed,
nodeEditAllowed: this.nodeEditAllowed,
},
});
},
});
});
import axios from '~/lib/utils/axios_utils';
import Api from '~/api';
export default class GeoNodesService {
constructor(nodeDetailsBasePath) {
this.geoNodeDetailsBasePath = nodeDetailsBasePath;
this.geoNodesPath = Api.buildUrl(Api.geoNodesPath);
}
getGeoNodes() {
return axios.get(this.geoNodesPath);
}
getGeoNodeDetails(nodeId) {
const geoNodeDetailsPath = `${this.geoNodeDetailsBasePath}/${nodeId}/status.json`;
return axios.get(geoNodeDetailsPath);
}
}
export default class GeoNodesStore {
constructor(primaryVersion, primaryRevision) {
this.state = {};
this.state.nodes = [];
this.state.nodeDetails = {};
this.state.primaryVersion = primaryVersion;
this.state.primaryRevision = primaryRevision;
}
setNodes(nodes) {
this.state.nodes = nodes;
}
getNodes() {
return this.state.nodes;
}
setNodeDetails(nodeId, nodeDetails) {
this.state.nodeDetails[nodeId] = GeoNodesStore.formatNodeDetails(nodeDetails);
}
getPrimaryNodeVersion() {
return {
version: this.state.primaryVersion,
revision: this.state.primaryRevision,
};
}
getNodeDetails(nodeId) {
return this.state.nodeDetails[nodeId];
}
static formatNodeDetails(rawNodeDetails) {
return {
id: rawNodeDetails.geo_node_id,
health: rawNodeDetails.health,
healthy: rawNodeDetails.healthy,
healthStatus: rawNodeDetails.health_status,
version: rawNodeDetails.version,
revision: rawNodeDetails.revision,
primaryVersion: rawNodeDetails.primaryVersion,
primaryRevision: rawNodeDetails.primaryRevision,
replicationSlotWAL: rawNodeDetails.replication_slots_max_retained_wal_bytes,
missingOAuthApplication: rawNodeDetails.missing_oauth_application,
storageShardsMatch: rawNodeDetails.storage_shards_match,
replicationSlots: {
totalCount: rawNodeDetails.replication_slots_count,
successCount: rawNodeDetails.replication_slots_used_count,
failureCount: 0,
},
repositories: {
totalCount: rawNodeDetails.repositories_count,
successCount: rawNodeDetails.repositories_synced_count,
failureCount: rawNodeDetails.repositories_failed_count,
},
wikis: {
totalCount: rawNodeDetails.wikis_count,
successCount: rawNodeDetails.wikis_synced_count,
failureCount: rawNodeDetails.wikis_failed_count,
},
lfs: {
totalCount: rawNodeDetails.lfs_objects_count,
successCount: rawNodeDetails.lfs_objects_failed_count,
failureCount: rawNodeDetails.lfs_objects_synced_count,
},
attachments: {
totalCount: rawNodeDetails.attachments_count,
successCount: rawNodeDetails.attachments_synced_count,
failureCount: rawNodeDetails.attachments_failed_count,
},
lastEvent: {
id: rawNodeDetails.last_event_id,
timeStamp: rawNodeDetails.last_event_timestamp,
},
cursorLastEvent: {
id: rawNodeDetails.cursor_last_event_id,
timeStamp: rawNodeDetails.cursor_last_event_timestamp,
},
namespaces: rawNodeDetails.namespaces,
dbReplicationLag: rawNodeDetails.db_replication_lag_seconds,
};
}
}
class Admin::GeoNodesController < Admin::ApplicationController
before_action :check_license, except: [:index, :destroy]
before_action :load_node, only: [:edit, :update, :destroy, :repair, :toggle, :status]
before_action :check_insecure_nodes
helper EE::GeoHelper
......@@ -105,16 +104,6 @@ class Admin::GeoNodesController < Admin::ApplicationController
@node = GeoNode.find(params[:id])
end
def check_insecure_nodes
if has_insecure_nodes?
flash_now(:alert, 'You have configured Geo nodes using an insecure HTTP connection. We recommend the use of HTTPS.')
end
end
def has_insecure_nodes?
GeoNode.with_url_prefix('http://').exists?
end
def flash_now(type, message)
flash.now[type] = flash.now[type].blank? ? message : "#{flash.now[type]}<BR>#{message}".html_safe
end
......
- page_title 'Geo nodes'
- @content_class = "geo-admin-container"
= webpack_bundle_tag 'common_vue'
= webpack_bundle_tag 'geo_nodes'
%h2.page-title.clearfix
%span.title-text.pull-left
Geo Nodes
= link_to "New node", new_admin_geo_node_path, class: 'btn btn-create pull-right'
%span.title-text.pull-left= _("Geo Nodes")
= link_to s_("GeoNodes|New node"), new_admin_geo_node_path, class: 'btn btn-create pull-right'
%hr.page-title-separator
%p.page-subtitle.light
......@@ -15,140 +18,7 @@
%strong exact order
they appear.
- current_primary = Gitlab::Geo.primary?
- if @nodes.any?
.panel.panel-default
.panel-heading
Geo nodes (#{@nodes.count})
%ul.well-list.geo-nodes
- @nodes.each do |node|
%li{ id: dom_id(node), class: node_class(node), data: { status_url: status_admin_geo_node_path(node, format: :json) } }
.node-block
= node_status_icon(node)
%strong= node.url
- if node.current?
.node-badge.current-node Current node
- if node.primary?
.node-badge.primary-node Primary
%p
%span.help-block Primary node
%p
%span.help-block
GitLab version:
%span.js-primary-version= Gitlab::VERSION
%small.js-primary-revision
= "(#{Gitlab::REVISION})"
- else
= status_loading_icon
%table.geo-node-status.js-geo-node-status.hidden
%tr
%td
.help-block
GitLab version:
%td
.node-info.prepend-top-5.prepend-left-5.js-secondary-version
- if current_primary
%tr
%td
.help-block
Storage config:
%td
.node-info.prepend-top-5.prepend-left-5.js-secondary-storage-shards
- if node.enabled?
%tr
%td
.help-block.prepend-top-10
Health Status:
%td
.health-status.prepend-top-10.prepend-left-5.js-health-status
%tr
%td
.help-block.prepend-top-10
Repositories:
%td
.node-info.prepend-top-10.prepend-left-5.stacked-progress-bar.js-repositories
%span.status-unavailable.js-stats-unavailable
Not available
%span.status-green.js-synced
%span.status-neutral.js-waiting
%span.status-red.js-failed
%tr
%td
.help-block.prepend-top-10
Wikis:
%td
.node-info.prepend-top-10.prepend-left-5.stacked-progress-bar.js-wikis
%span.status-unavailable.js-stats-unavailable
Not available
%span.status-green.js-synced
%span.status-neutral.js-waiting
%span.status-red.js-failed
%tr
%td
.help-block.prepend-top-10
LFS objects:
%td
.node-info.prepend-top-10.prepend-left-5.stacked-progress-bar.js-lfs-objects
%span.status-unavailable.js-stats-unavailable
Not available
%span.status-green.js-synced
%span.status-neutral.js-waiting
%span.status-red.js-failed
%tr
%td
.help-block.prepend-top-10
Attachments:
%td
.node-info.prepend-top-10.prepend-left-5.stacked-progress-bar.js-attachments
%span.status-unavailable.js-stats-unavailable
Not available
%span.status-green.js-synced
%span.status-neutral.js-waiting
%span.status-red.js-failed
%tr
%td
.help-block.prepend-top-10
Sync settings:
%td
.node-info.prepend-top-10.prepend-left-5.js-sync-settings
%span.js-sync-type
%span.has-tooltip.sync-status.js-sync-status
%i.sync-status-icon.js-sync-status-icon
%span.sync-status-timestamp.js-sync-status-timestamp
%tr.js-advanced-status.hidden
%td
.help-block.prepend-top-10
Database replication lag:
%td
.node-info.prepend-top-10.prepend-left-5.js-db-replication-lag
%tr.js-advanced-status.hidden
%td
.help-block.prepend-top-10
Last event ID seen from primary:
%td
.node-info.prepend-top-10.prepend-left-5.js-last-event-seen
%span.js-event-id
%span.event-timestamp.js-event-timestamp.has-tooltip
%tr.js-advanced-status.hidden
%td
.help-block.prepend-top-10
Last event ID processed by cursor:
%td
.node-info.prepend-top-10.prepend-left-5.js-last-cursor-event
%span.js-event-id
%span.event-timestamp.js-event-timestamp.has-tooltip
%button.btn-link.advanced-geo-node-status-toggler.js-advanced-geo-node-status-toggler
%span> Advanced
%span.js-advance-toggle.show-advance-chevron.pull-right.inline.prepend-left-5
= sprite_icon('angle-down', css_class: 's16')
%p.health-message.hidden.js-health-message
- if Gitlab::Database.read_write?
.node-actions
- if Gitlab::Geo.license_allows?
- if node.missing_oauth_application?
= link_to "Repair authentication", repair_admin_geo_node_path(node), method: :post, title: 'OAuth application is missing', class: 'btn btn-default btn-sm'
- if node.secondary?
= toggle_node_button(node)
= link_to "Edit", edit_admin_geo_node_path(node), class: 'btn btn-sm'
= link_to "Remove", admin_geo_node_path(node), data: { confirm: 'Are you sure?' }, method: :delete, class: 'btn btn-remove btn-sm'
#js-geo-nodes{ data: { primary_version: "#{Gitlab::VERSION}", primary_revision: "#{Gitlab::REVISION}", node_details_path: "#{admin_geo_nodes_path}", node_actions_allowed: "#{Gitlab::Database.read_write?}", node_edit_allowed: "#{Gitlab::Geo.license_allows?}" } }
- else
= render 'shared/empty_states/geo'
.row.empty-state
.col-xs-12
.svg-content
= image_tag 'illustrations/gitlab_geo.svg'
.col-xs-12
.text-content.text-center
%h4= _("Discover GitLab Geo.")
%p= _("Make everyone on your team more productive regardless of their location. GitLab Geo creates read-only mirrors of your GitLab instance so you can reduce the time it takes to clone and fetch large repos.")
= link_to _('Learn more'), 'https://about.gitlab.com/features/gitlab-geo/', rel: 'nofollow', class: 'btn btn-new'
......@@ -53,30 +53,6 @@ describe Admin::GeoNodesController, :postgresql do
expect(response).not_to redirect_to(admin_license_path)
end
end
context 'Secured URL' do
let(:alert_message) { 'You have configured Geo nodes using an insecure HTTP connection. We recommend the use of HTTPS.' }
context 'HTTP nodes' do
it 'displays a flash message' do
create(:geo_node, url: 'http://not.safe')
go
expect(flash[:alert]).to include(alert_message)
end
end
context 'with HTTPS nodes' do
it 'does not display a flash message' do
create(:geo_node, url: 'https://much.safer')
go
expect(flash[:alert]).not_to include(alert_message)
end
end
end
end
describe '#destroy' do
......
require 'spec_helper'
RSpec.describe 'admin Geo Nodes', type: :feature do
describe 'admin Geo Nodes', :js do
let!(:geo_node) { create(:geo_node) }
before do
......@@ -10,6 +10,7 @@ RSpec.describe 'admin Geo Nodes', type: :feature do
it 'show all public Geo Nodes and create new node link' do
visit admin_geo_nodes_path
wait_for_requests
expect(page).to have_link('New node', href: new_admin_geo_node_path)
page.within(find('.geo-nodes', match: :first)) do
......@@ -30,6 +31,7 @@ RSpec.describe 'admin Geo Nodes', type: :feature do
click_button 'Add Node'
expect(current_path).to eq admin_geo_nodes_path
wait_for_requests
page.within(find('.geo-nodes', match: :first)) do
expect(page).to have_content(geo_node.url)
......@@ -52,7 +54,8 @@ RSpec.describe 'admin Geo Nodes', type: :feature do
describe 'update an existing Geo Node' do
before do
visit admin_geo_nodes_path
page.within(find('.node-actions', match: :first)) do
wait_for_requests
page.within(find('.geo-node-actions', match: :first)) do
page.click_link('Edit')
end
end
......@@ -63,6 +66,7 @@ RSpec.describe 'admin Geo Nodes', type: :feature do
click_button 'Save changes'
expect(current_path).to eq admin_geo_nodes_path
wait_for_requests
page.within(find('.geo-nodes', match: :first)) do
expect(page).to have_content('http://newsite.com')
......@@ -74,14 +78,16 @@ RSpec.describe 'admin Geo Nodes', type: :feature do
describe 'remove an existing Geo Node' do
before do
visit admin_geo_nodes_path
wait_for_requests
end
it 'removes an existing Geo Node' do
page.within(find('.node-actions', match: :first)) do
page.within(find('.geo-node-actions', match: :first)) do
page.click_link('Remove')
end
expect(current_path).to eq admin_geo_nodes_path
wait_for_requests
expect(page).not_to have_css('.geo-nodes')
end
end
......
import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import appComponent from 'ee/geo_nodes/components/app.vue';
import eventHub from 'ee/geo_nodes/event_hub';
import GeoNodesStore from 'ee/geo_nodes/store/geo_nodes_store';
import GeoNodesService from 'ee/geo_nodes/service/geo_nodes_service';
import { PRIMARY_VERSION, NODE_DETAILS_PATH, mockNodes, rawMockNodeDetails } from '../mock_data';
import mountComponent from '../../helpers/vue_mount_component_helper';
const createComponent = () => {
const Component = Vue.extend(appComponent);
const store = new GeoNodesStore(PRIMARY_VERSION.version, PRIMARY_VERSION.revision);
const service = new GeoNodesService(NODE_DETAILS_PATH);
return mountComponent(Component, {
store,
service,
nodeActionsAllowed: true,
nodeEditAllowed: true,
});
};
describe('AppComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('data', () => {
it('returns default data props', () => {
expect(vm.isLoading).toBeTruthy();
expect(vm.hasError).toBeFalsy();
expect(vm.errorMessage).toBe('');
});
});
describe('computed', () => {
describe('nodes', () => {
it('returns list of nodes from store', () => {
expect(Array.isArray(vm.nodes)).toBeTruthy();
});
});
});
describe('methods', () => {
describe('fetchGeoNodes', () => {
it('calls service.getGeoNodes and sets response to the store on success', (done) => {
const mock = new MockAdapter(axios);
mock.onGet(vm.store.geoNodesPath).reply(200, mockNodes);
spyOn(vm.store, 'setNodes');
vm.fetchGeoNodes();
expect(vm.hasError).toBeFalsy();
setTimeout(() => {
expect(vm.store.setNodes).toHaveBeenCalledWith(mockNodes);
expect(vm.isLoading).toBeFalsy();
done();
}, 0);
});
it('sets error flag and message on failure', (done) => {
const err = 'Something went wrong';
const mock = new MockAdapter(axios);
mock.onGet(vm.store.geoNodesPath).reply(500, err);
vm.fetchGeoNodes();
expect(vm.hasError).toBeFalsy();
setTimeout(() => {
expect(vm.hasError).toBeTruthy();
expect(vm.errorMessage.response.data).toBe(err);
done();
}, 0);
});
});
describe('fetchNodeDetails', () => {
it('calls service.getGeoNodeDetails and sets response to the store on success', (done) => {
const mock = new MockAdapter(axios);
mock.onGet(`${vm.service.geoNodeDetailsBasePath}/2/status.json`).reply(200, rawMockNodeDetails);
spyOn(vm.store, 'setNodeDetails');
vm.fetchNodeDetails(2);
setTimeout(() => {
expect(vm.store.setNodeDetails).toHaveBeenCalled();
done();
}, 0);
});
it('sets error flag and message on failure', (done) => {
const err = 'Something went wrong';
const mock = new MockAdapter(axios);
mock.onGet(`${vm.service.geoNodeDetailsBasePath}/2/status.json`).reply(500, err);
vm.fetchNodeDetails(2);
setTimeout(() => {
expect(vm.hasError).toBeTruthy();
expect(vm.errorMessage.response.data).toBe(err);
done();
}, 0);
});
});
describe('initNodeDetailsPolling', () => {
it('initializes SmartInterval and sets it to component', () => {
vm.initNodeDetailsPolling(2);
expect(vm.nodePollingInterval).toBeDefined();
});
});
});
describe('created', () => {
it('binds event handler for `pollNodeDetails`', () => {
spyOn(eventHub, '$on');
const vmX = createComponent();
expect(eventHub.$on).toHaveBeenCalledWith('pollNodeDetails', jasmine.any(Function));
vmX.$destroy();
});
});
describe('beforeDestroy', () => {
it('unbinds event handler for `pollNodeDetails`', () => {
spyOn(eventHub, '$off');
const vmX = createComponent();
vmX.$destroy();
expect(eventHub.$off).toHaveBeenCalledWith('pollNodeDetails', jasmine.any(Function));
});
});
describe('template', () => {
it('renders container elements correctly', () => {
expect(vm.$el.classList.contains('panel', 'panel-default')).toBeTruthy();
expect(vm.$el.querySelectorAll('.panel-heading').length).not.toBe(0);
expect(vm.$el.querySelector('.panel-heading').innerText.trim()).toBe('Geo nodes (0)');
});
it('renders loading animation when `isLoading` is true', () => {
vm.isLoading = true;
expect(vm.$el.querySelectorAll('.loading-animation.prepend-top-20.append-bottom-20').length).not.toBe(0);
});
it('renders list of nodes', (done) => {
vm.store.setNodes(mockNodes);
vm.isLoading = false;
Vue.nextTick(() => {
expect(vm.$el.querySelectorAll('.loading-animation.prepend-top-20.append-bottom-20').length).toBe(0);
expect(vm.$el.querySelectorAll('ul.geo-nodes').length).not.toBe(0);
done();
});
});
it('renders error message', (done) => {
vm.hasError = true;
vm.isLoading = false;
vm.errorMessage = 'Something went wrong.';
Vue.nextTick(() => {
const errEl = 'p.health-message.prepend-left-15.append-right-15';
expect(vm.$el.querySelectorAll(errEl).length).not.toBe(0);
expect(vm.$el.querySelector(errEl).innerText.trim()).toBe(vm.errorMessage);
done();
});
});
});
});
import Vue from 'vue';
import geoNodeActionsComponent from 'ee/geo_nodes/components/geo_node_actions.vue';
import { mockNodes } from '../mock_data';
import mountComponent from '../../helpers/vue_mount_component_helper';
const createComponent = (node = mockNodes[0], nodeEditAllowed = true, nodeMissingOauth = false) => {
const Component = Vue.extend(geoNodeActionsComponent);
return mountComponent(Component, {
node,
nodeEditAllowed,
nodeMissingOauth,
});
};
describe('GeoNodeActionsComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('data', () => {
it('returns default data props', () => {
const vmX = createComponent();
expect(vmX.isNodeToggleInProgress).toBeFalsy();
vmX.$destroy();
});
});
describe('computed', () => {
describe('isToggleAllowed', () => {
it('returns boolean value representing if toggle on node can be allowed', () => {
let vmX = createComponent(mockNodes[0], true, false);
expect(vmX.isToggleAllowed).toBeFalsy();
vmX.$destroy();
vmX = createComponent(mockNodes[1]);
expect(vmX.isToggleAllowed).toBeTruthy();
vmX.$destroy();
});
});
describe('nodeToggleLabel', () => {
it('returns label for toggle button for a node', () => {
let mockNode = Object.assign({}, mockNodes[1]);
let vmX = createComponent(mockNode);
expect(vmX.nodeToggleLabel).toBe('Disable');
vmX.$destroy();
mockNode = Object.assign({}, mockNodes[1], { enabled: false });
vmX = createComponent(mockNode);
expect(vmX.nodeToggleLabel).toBe('Enable');
vmX.$destroy();
});
});
describe('nodeDisableMessage', () => {
it('returns node toggle message', () => {
let mockNode = Object.assign({}, mockNodes[1]);
let vmX = createComponent(mockNode);
expect(vmX.nodeDisableMessage).toBe('Disabling a node stops the sync process. Are you sure?');
vmX.$destroy();
mockNode = Object.assign({}, mockNodes[1], { enabled: false });
vmX = createComponent(mockNode);
expect(vmX.nodeDisableMessage).toBe('');
vmX.$destroy();
});
});
describe('nodePath', () => {
it('returns node path', () => {
expect(vm.nodePath).toBe('/admin/geo_nodes/1');
});
});
describe('nodeRepairAuthPath', () => {
it('returns node repair authentication path', () => {
expect(vm.nodeRepairAuthPath).toBe('/admin/geo_nodes/1/reconfigure');
});
});
describe('nodeTogglePath', () => {
it('returns node toggle path', () => {
expect(vm.nodeTogglePath).toBe('/admin/geo_nodes/1/toggle');
});
});
describe('nodeEditPath', () => {
it('returns node edit path', () => {
expect(vm.nodeEditPath).toBe('/admin/geo_nodes/1/edit');
});
});
});
describe('template', () => {
it('renders container elements correctly', () => {
expect(vm.$el.classList.contains('geo-node-actions')).toBeTruthy();
expect(vm.$el.querySelectorAll('.node-action-container').length).not.toBe(0);
expect(vm.$el.querySelectorAll('.btn-node-action').length).not.toBe(0);
});
});
});
import Vue from 'vue';
import geoNodeDetailItemComponent from 'ee/geo_nodes/components/geo_node_detail_item.vue';
import { VALUE_TYPE, CUSTOM_TYPE } from 'ee/geo_nodes/constants';
import { rawMockNodeDetails } from '../mock_data';
import mountComponent from '../../helpers/vue_mount_component_helper';
const createComponent = (config) => {
const Component = Vue.extend(geoNodeDetailItemComponent);
const defaultConfig = Object.assign({
itemTitle: 'GitLab version:',
cssClass: 'node-version',
itemValue: '10.4.0-pre',
successLabel: 'Synced',
failureLabel: 'Failed',
neutralLabel: 'Out of sync',
itemValueType: VALUE_TYPE.PLAIN,
}, config);
return mountComponent(Component, defaultConfig);
};
describe('GeoNodeDetailItemComponent', () => {
describe('template', () => {
it('renders container elements correctly', () => {
const vm = createComponent();
expect(vm.$el.classList.contains('node-detail-item')).toBeTruthy();
expect(vm.$el.querySelectorAll('.node-detail-title').length).not.toBe(0);
expect(vm.$el.querySelector('.node-detail-title').innerText.trim()).toBe('GitLab version:');
vm.$destroy();
});
it('renders plain item value', () => {
const vm = createComponent();
expect(vm.$el.querySelectorAll('.node-detail-value').length).not.toBe(0);
expect(vm.$el.querySelector('.node-detail-value').innerText.trim()).toBe('10.4.0-pre');
vm.$destroy();
});
it('renders graph item value', () => {
const vm = createComponent({
itemValueType: VALUE_TYPE.GRAPH,
itemValue: { successCount: 5, failureCount: 3, totalCount: 10 },
});
expect(vm.$el.querySelectorAll('.stacked-progress-bar').length).not.toBe(0);
vm.$destroy();
});
it('renders health status item value', () => {
const vm = createComponent({
itemValueType: VALUE_TYPE.CUSTOM,
customType: CUSTOM_TYPE.STATUS,
itemValue: rawMockNodeDetails.health,
});
expect(vm.$el.querySelectorAll('.node-health-status').length).not.toBe(0);
vm.$destroy();
});
it('renders sync settings item value', () => {
const vm = createComponent({
itemValueType: VALUE_TYPE.CUSTOM,
customType: CUSTOM_TYPE.SYNC,
itemValue: {
namespaces: rawMockNodeDetails.namespaces,
lastEvent: {
id: rawMockNodeDetails.last_event_id,
timeStamp: rawMockNodeDetails.last_event_timestamp,
},
cursorLastEvent: {
id: rawMockNodeDetails.cursor_last_event_id,
timeStamp: rawMockNodeDetails.cursor_last_event_timestamp,
},
},
});
expect(vm.$el.querySelectorAll('.node-sync-settings').length).not.toBe(0);
vm.$destroy();
});
it('renders event status item value', () => {
const vm = createComponent({
itemValueType: VALUE_TYPE.CUSTOM,
customType: CUSTOM_TYPE.EVENT,
itemValue: {
eventId: rawMockNodeDetails.last_event_id,
eventTimeStamp: rawMockNodeDetails.last_event_timestamp,
},
});
expect(vm.$el.querySelectorAll('.event-status-timestamp').length).not.toBe(0);
vm.$destroy();
});
});
});
import Vue from 'vue';
import geoNodeDetailsComponent from 'ee/geo_nodes/components/geo_node_details.vue';
import { mockNodes, mockNodeDetails } from '../mock_data';
import mountComponent from '../../helpers/vue_mount_component_helper';
const createComponent = (nodeDetails = mockNodeDetails) => {
const Component = Vue.extend(geoNodeDetailsComponent);
return mountComponent(Component, {
nodeDetails,
node: mockNodes[1],
});
};
describe('GeoNodeDetailsComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('data', () => {
it('returns default data props', () => {
expect(vm.showAdvanceItems).toBeFalsy();
expect(vm.errorMessage).toBe('');
expect(Array.isArray(vm.nodeDetailItems)).toBeTruthy();
});
});
describe('computed', () => {
describe('hasError', () => {
it('returns boolean value representing if node has any errors', () => {
// With altered mock data for Unhealthy status
const nodeDetails = Object.assign({}, mockNodeDetails, {
health: 'Something went wrong.',
healthy: false,
});
const vmX = createComponent(nodeDetails);
expect(vmX.errorMessage).toBe('Something went wrong.');
expect(vmX.hasError).toBeTruthy();
vmX.$destroy();
// With default mock data
expect(vm.hasError).toBeFalsy();
});
});
describe('hasVersionMismatch', () => {
it('returns boolean value representing if node has version mismatch', () => {
// With altered mock data for version mismatch
const nodeDetails = Object.assign({}, mockNodeDetails, {
primaryVersion: '10.3.0-pre',
primaryRevision: 'b93c51850b',
});
const vmX = createComponent(nodeDetails);
expect(vmX.errorMessage).toBe('GitLab version does not match the primary node version');
expect(vmX.hasVersionMismatch).toBeTruthy();
vmX.$destroy();
// With default mock data
expect(vm.hasVersionMismatch).toBeFalsy();
});
});
describe('advanceButtonIcon', () => {
it('returns button icon name', () => {
vm.showAdvanceItems = true;
expect(vm.advanceButtonIcon).toBe('angle-up');
vm.showAdvanceItems = false;
expect(vm.advanceButtonIcon).toBe('angle-down');
});
});
describe('nodeVersion', () => {
it('returns version string', () => {
expect(vm.nodeVersion).toBe('10.4.0-pre (b93c51849b)');
});
});
describe('replicationSlotWAL', () => {
it('returns replication slot WAL in Megabytes', () => {
expect(vm.replicationSlotWAL).toBe('0 MB');
});
});
describe('dbReplicationLag', () => {
it('returns DB replication lag time duration', () => {
expect(vm.dbReplicationLag).toBe('0m');
});
});
describe('lastEventStatus', () => {
it('returns event status object', () => {
expect(vm.lastEventStatus.eventId).toBe(mockNodeDetails.lastEvent.id);
expect(vm.lastEventStatus.eventTimeStamp).toBe(mockNodeDetails.lastEvent.timeStamp);
});
});
describe('cursorLastEventStatus', () => {
it('returns event status object', () => {
expect(vm.cursorLastEventStatus.eventId).toBe(mockNodeDetails.cursorLastEvent.id);
expect(vm.cursorLastEventStatus.eventTimeStamp)
.toBe(mockNodeDetails.cursorLastEvent.timeStamp);
});
});
});
describe('methods', () => {
describe('nodeHealthStatus', () => {
it('returns health status string', () => {
// With altered mock data for Unhealthy status
const nodeDetails = Object.assign({}, mockNodeDetails, {
healthStatus: 'Unhealthy',
healthy: false,
});
const vmX = createComponent(nodeDetails);
expect(vmX.nodeHealthStatus()).toBe('Unhealthy');
vmX.$destroy();
// With default mock data
expect(vm.nodeHealthStatus()).toBe('Healthy');
});
});
describe('storageShardsStatus', () => {
it('returns storage shard status string', () => {
// With altered mock data for Unhealthy status
let nodeDetails = Object.assign({}, mockNodeDetails, {
storageShardsMatch: null,
});
let vmX = createComponent(nodeDetails);
expect(vmX.storageShardsStatus()).toBe('Unknown');
vmX.$destroy();
nodeDetails = Object.assign({}, mockNodeDetails, {
storageShardsMatch: true,
});
vmX = createComponent(nodeDetails);
expect(vmX.storageShardsStatus()).toBe('OK');
vmX.$destroy();
// With default mock data
expect(vm.storageShardsStatus()).toBe('Does not match the primary storage configuration');
});
});
describe('plainValueCssClass', () => {
it('returns CSS class for plain value item', () => {
expect(vm.plainValueCssClass()).toBe('node-detail-value-bold');
expect(vm.plainValueCssClass(true)).toBe('node-detail-value-bold node-detail-value-error');
});
});
describe('syncSettings', () => {
it('returns sync settings object', () => {
const syncSettings = vm.syncSettings();
expect(syncSettings.namespaces).toBe(mockNodeDetails.namespaces);
expect(syncSettings.lastEvent).toBe(mockNodeDetails.lastEvent);
expect(syncSettings.cursorLastEvent).toBe(mockNodeDetails.cursorLastEvent);
});
});
describe('onClickShowAdvance', () => {
it('toggles `showAdvanceItems` prop', () => {
vm.showAdvanceItems = true;
vm.onClickShowAdvance();
expect(vm.showAdvanceItems).toBeFalsy();
vm.showAdvanceItems = false;
vm.onClickShowAdvance();
expect(vm.showAdvanceItems).toBeTruthy();
});
});
});
describe('template', () => {
it('renders container elements correctly', () => {
expect(vm.$el.querySelectorAll('.node-details-list').length).not.toBe(0);
expect(vm.$el.querySelectorAll('.btn-show-advanced').length).not.toBe(0);
});
});
});
import Vue from 'vue';
import geoNodeEventStatusComponent from 'ee/geo_nodes/components/geo_node_event_status.vue';
import { mockNodeDetails } from '../mock_data';
import mountComponent from '../../helpers/vue_mount_component_helper';
const createComponent = () => {
const Component = Vue.extend(geoNodeEventStatusComponent);
return mountComponent(Component, {
eventId: mockNodeDetails.lastEvent.id,
eventTimeStamp: mockNodeDetails.lastEvent.timeStamp,
});
};
describe('GeoNodeEventStatus', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('computed', () => {
describe('timeStamp', () => {
it('returns timestamp Date object', () => {
expect(vm.timeStamp instanceof Date).toBeTruthy();
});
});
describe('timeStampString', () => {
it('returns formatted timestamp string', () => {
expect(vm.timeStampString).toContain('Nov 21, 2017');
});
});
});
describe('template', () => {
it('renders container elements correctly', () => {
expect(vm.$el.classList.contains('node-detail-value')).toBeTruthy();
expect(vm.$el.querySelectorAll('strong').length).not.toBe(0);
expect(vm.$el.querySelector('strong').innerText.trim()).toBe(`${mockNodeDetails.lastEvent.id}`);
expect(vm.$el.querySelector('.event-status-timestamp').innerText).toContain('ago');
});
});
});
import Vue from 'vue';
import geoNodeHealthStatusComponent from 'ee/geo_nodes/components/geo_node_health_status.vue';
import { mockNodeDetails } from '../mock_data';
import mountComponent from '../../helpers/vue_mount_component_helper';
const createComponent = (status = mockNodeDetails.health) => {
const Component = Vue.extend(geoNodeHealthStatusComponent);
return mountComponent(Component, {
status,
});
};
describe('GeoNodeHealthStatusComponent', () => {
describe('computed', () => {
describe('healthCssClass', () => {
it('returns CSS class representing `status` prop value', () => {
const vm = createComponent('Healthy');
expect(vm.healthCssClass).toBe('geo-node-healthy');
vm.$destroy();
});
});
describe('statusIconName', () => {
it('returns icon name representing `status` prop value', () => {
let vm = createComponent('Healthy');
expect(vm.statusIconName).toBe('status_success');
vm.$destroy();
vm = createComponent('Unhealthy');
expect(vm.statusIconName).toBe('status_failed');
vm.$destroy();
vm = createComponent('Disabled');
expect(vm.statusIconName).toBe('status_canceled');
vm.$destroy();
vm = createComponent('Unknown');
expect(vm.statusIconName).toBe('status_warning');
vm.$destroy();
vm = createComponent('Offline');
expect(vm.statusIconName).toBe('status_canceled');
vm.$destroy();
});
});
});
describe('template', () => {
it('renders container elements correctly', () => {
const vm = createComponent('Healthy');
expect(vm.$el.classList.contains('node-detail-value', 'node-health-status', 'geo-node-healthy')).toBeTruthy();
expect(vm.$el.querySelectorAll('svg').length).not.toBe(0);
expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('#status_success');
expect(vm.$el.querySelector('.status-text').innerText.trim()).toBe('Healthy');
vm.$destroy();
});
});
});
import Vue from 'vue';
import geoNodeItemComponent from 'ee/geo_nodes/components/geo_node_item.vue';
import eventHub from 'ee/geo_nodes/event_hub';
import { mockNodes, mockNodeDetails } from '../mock_data';
import mountComponent from '../../helpers/vue_mount_component_helper';
const createComponent = (node = mockNodes[0]) => {
const Component = Vue.extend(geoNodeItemComponent);
return mountComponent(Component, {
node,
primaryNode: true,
nodeActionsAllowed: true,
nodeEditAllowed: true,
});
};
describe('GeoNodeItemComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('data', () => {
it('returns default data props', () => {
expect(vm.isNodeDetailsLoading).toBeTruthy();
expect(vm.nodeHealthStatus).toBe('');
expect(typeof vm.nodeDetails).toBe('object');
});
});
describe('computed', () => {
describe('showInsecureUrlWarning', () => {
it('returns boolean value representing URL protocol security', () => {
// With altered mock data for secure URL
const mockNode = Object.assign({}, mockNodes[0], {
url: 'https://127.0.0.1:3001/',
});
const vmX = createComponent(mockNode);
expect(vmX.showInsecureUrlWarning).toBeFalsy();
vmX.$destroy();
// With default mock data
expect(vm.showInsecureUrlWarning).toBeTruthy();
});
});
});
describe('methods', () => {
describe('handleNodeDetails', () => {
it('intializes props based on provided `nodeDetails`', () => {
// With altered mock data with matching ID
const mockNode = Object.assign({}, mockNodes[1]);
const vmX = createComponent(mockNode);
vmX.handleNodeDetails(mockNodeDetails);
expect(vmX.isNodeDetailsLoading).toBeFalsy();
expect(vmX.nodeDetails).toBe(mockNodeDetails);
expect(vmX.nodeHealthStatus).toBe(mockNodeDetails.health);
vmX.$destroy();
// With default mock data without matching ID
vm.handleNodeDetails(mockNodeDetails);
expect(vm.isNodeDetailsLoading).toBeTruthy();
expect(vm.nodeDetails).not.toBe(mockNodeDetails);
expect(vm.nodeHealthStatus).not.toBe(mockNodeDetails.health);
});
});
describe('handleMounted', () => {
it('emits `pollNodeDetails` event and passes node ID', () => {
spyOn(eventHub, '$emit');
vm.handleMounted();
expect(eventHub.$emit).toHaveBeenCalledWith('pollNodeDetails', mockNodes[0].id);
});
});
});
describe('created', () => {
it('binds `nodeDetailsLoaded` event handler', () => {
spyOn(eventHub, '$on');
const vmX = createComponent();
expect(eventHub.$on).toHaveBeenCalledWith('nodeDetailsLoaded', jasmine.any(Function));
vmX.$destroy();
});
});
describe('beforeDestroy', () => {
it('unbinds `nodeDetailsLoaded` event handler', () => {
spyOn(eventHub, '$off');
const vmX = createComponent();
vmX.$destroy();
expect(eventHub.$off).toHaveBeenCalledWith('nodeDetailsLoaded', jasmine.any(Function));
});
});
describe('template', () => {
it('renders node URL', () => {
expect(vm.$el.querySelectorAll('.node-url').length).not.toBe(0);
});
it('renders node details loading animation', () => {
vm.isNodeDetailsLoading = true;
expect(vm.$el.querySelectorAll('.node-details-loading').length).not.toBe(0);
});
it('renders node badge `Current node`', () => {
expect(vm.$el.querySelectorAll('.node-badge.current-node').length).not.toBe(0);
});
it('renders node badge `Primary`', () => {
expect(vm.$el.querySelectorAll('.node-badge.primary-node').length).not.toBe(0);
});
});
});
import Vue from 'vue';
import geoNodeSyncSettingsComponent from 'ee/geo_nodes/components/geo_node_sync_settings.vue';
import { mockNodeDetails } from '../mock_data';
import mountComponent from '../../helpers/vue_mount_component_helper';
const createComponent = (
namespaces = mockNodeDetails.namespaces,
lastEvent = mockNodeDetails.lastEvent,
cursorLastEvent = mockNodeDetails.cursorLastEvent) => {
const Component = Vue.extend(geoNodeSyncSettingsComponent);
return mountComponent(Component, {
namespaces,
lastEvent,
cursorLastEvent,
});
};
describe('GeoNodeSyncSettingsComponent', () => {
describe('computed', () => {
describe('syncType', () => {
it('returns string representing sync type', () => {
const vm = createComponent();
expect(vm.syncType).toBe('Selective');
vm.$destroy();
});
});
});
describe('methods', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('lagInSeconds', () => {
it('returns string representing sync type', () => {
expect(vm.lagInSeconds(1511255200, 1511255450)).toBe(250);
});
});
describe('statusIcon', () => {
it('returns string representing sync status icon', () => {
expect(vm.statusIcon(250)).toBe('retry');
expect(vm.statusIcon(3500)).toBe('warning');
expect(vm.statusIcon(4000)).toBe('status_failed');
});
});
describe('statusEventInfo', () => {
it('returns string representing status event info', () => {
expect(vm.statusEventInfo(3, 3, 250)).toBe('4 minutes 10 seconds (0 events)');
});
});
describe('statusTooltip', () => {
it('returns string representing status lag message', () => {
expect(vm.statusTooltip(250)).toBe('');
expect(vm.statusTooltip(1000)).toBe('Node is slow, overloaded, or it just recovered after an outage.');
expect(vm.statusTooltip(4000)).toBe('Node is failing or broken.');
});
});
});
});
import Vue from 'vue';
import geoNodesListComponent from 'ee/geo_nodes/components/geo_nodes_list.vue';
import { mockNodes } from '../mock_data';
import mountComponent from '../../helpers/vue_mount_component_helper';
const createComponent = () => {
const Component = Vue.extend(geoNodesListComponent);
return mountComponent(Component, {
nodes: mockNodes,
nodeActionsAllowed: true,
nodeEditAllowed: true,
});
};
describe('GeoNodesListComponent', () => {
describe('template', () => {
it('renders container element correctly', () => {
const vm = createComponent();
expect(vm.$el.classList.contains('well-list', 'geo-nodes')).toBeTruthy();
vm.$destroy();
});
});
});
export const PRIMARY_VERSION = {
version: '10.4.0-pre',
revision: 'b93c51849b',
};
export const NODE_DETAILS_PATH = '/admin/geo_nodes';
export const mockNodes = [
{
id: 1,
url: 'http://127.0.0.1:3001/',
primary: true,
enabled: true,
current: true,
files_max_capacity: 10,
repos_max_capacity: 25,
clone_protocol: 'http',
},
{
id: 2,
url: 'http://127.0.0.1:3002/',
primary: false,
enabled: true,
current: false,
files_max_capacity: 10,
repos_max_capacity: 25,
clone_protocol: 'http',
},
];
export const rawMockNodeDetails = {
geo_node_id: 2,
healthy: true,
health: 'Healthy',
health_status: 'Healthy',
missing_oauth_application: false,
attachments_count: 0,
attachments_synced_count: 0,
attachments_failed_count: 0,
attachments_synced_in_percentage: '0.00%',
db_replication_lag_seconds: 0,
lfs_objects_count: 0,
lfs_objects_synced_count: 0,
lfs_objects_failed_count: 0,
lfs_objects_synced_in_percentage: '0.00%',
repositories_count: 12,
repositories_failed_count: 0,
repositories_synced_count: 12,
repositories_synced_in_percentage: '100.00%',
wikis_count: 12,
wikis_failed_count: 0,
wikis_synced_count: 12,
wikis_synced_in_percentage: '100.00%',
replication_slots_count: null,
replication_slots_used_count: null,
replication_slots_used_in_percentage: '0.00%',
replication_slots_max_retained_wal_bytes: null,
last_event_id: 3,
last_event_timestamp: 1511255200,
cursor_last_event_id: 3,
cursor_last_event_timestamp: 1511255200,
last_successful_status_check_timestamp: 1515142330,
version: '10.4.0-pre',
revision: 'b93c51849b',
namespaces: [
{
id: 54,
name: 'platform',
path: 'platform',
kind: 'group',
full_path: 'platform',
parent_id: null,
},
{
id: 4,
name: 'Twitter',
path: 'twitter',
kind: 'group',
full_path: 'twitter',
parent_id: null,
},
{
id: 3,
name: 'Documentcloud',
path: 'documentcloud',
kind: 'group',
full_path: 'documentcloud',
parent_id: null,
},
],
storage_shards: [
{
name: 'default',
path: '/home/kushal/GitLab/geo/repositorie',
},
],
storage_shards_match: false,
};
export const mockNodeDetails = {
id: 2,
health: 'Healthy',
healthy: true,
healthStatus: 'Healthy',
version: '10.4.0-pre',
revision: 'b93c51849b',
primaryVersion: '10.4.0-pre',
primaryRevision: 'b93c51849b',
replicationSlotWAL: null,
missingOAuthApplication: false,
storageShardsMatch: false,
replicationSlots: {
totalCount: null,
successCount: null,
failureCount: 0,
},
repositories: {
totalCount: 12,
successCount: 12,
failureCount: 0,
},
wikis: {
totalCount: 12,
successCount: 12,
failureCount: 0,
},
lfs: {
totalCount: 0,
successCount: 0,
failureCount: 0,
},
attachments: {
totalCount: 0,
successCount: 0,
failureCount: 0,
},
lastEvent: {
id: 3,
timeStamp: 1511255200,
},
cursorLastEvent: {
id: 3,
timeStamp: 1511255200,
},
namespaces: [
{
id: 54,
name: 'platform',
path: 'platform',
kind: 'group',
full_path: 'platform',
parent_id: null,
},
{
id: 4,
name: 'Twitter',
path: 'twitter',
kind: 'group',
full_path: 'twitter',
parent_id: null,
},
{
id: 3,
name: 'Documentcloud',
path: 'documentcloud',
kind: 'group',
full_path: 'documentcloud',
parent_id: null,
},
],
dbReplicationLag: 0,
};
import axios from '~/lib/utils/axios_utils';
import GeoNodesService from 'ee/geo_nodes/service/geo_nodes_service';
import { NODE_DETAILS_PATH } from '../mock_data';
describe('GeoNodesService', () => {
let service;
beforeEach(() => {
service = new GeoNodesService(NODE_DETAILS_PATH);
});
describe('getGeoNodes', () => {
it('returns axios instance for Geo nodes path', () => {
spyOn(axios, 'get').and.stub();
service.getGeoNodes();
expect(axios.get).toHaveBeenCalledWith(service.geoNodesPath);
});
});
describe('getGeoNodeDetails', () => {
it('returns axios instance for Geo node details path', () => {
spyOn(axios, 'get').and.stub();
service.getGeoNodeDetails(2);
expect(axios.get).toHaveBeenCalled();
});
});
});
import GeoNodesStore from 'ee/geo_nodes/store/geo_nodes_store';
import { mockNodes, rawMockNodeDetails, mockNodeDetails } from '../mock_data';
describe('GeoNodesStore', () => {
let store;
beforeEach(() => {
store = new GeoNodesStore(mockNodeDetails.primaryVersion, mockNodeDetails.primaryRevision);
});
describe('constructor', () => {
it('initializes default state', () => {
expect(typeof store.state).toBe('object');
expect(Array.isArray(store.state.nodes)).toBeTruthy();
expect(typeof store.state.nodeDetails).toBe('object');
expect(store.state.primaryVersion).toBe(mockNodeDetails.primaryVersion);
expect(store.state.primaryRevision).toBe(mockNodeDetails.primaryRevision);
});
});
describe('setNodes', () => {
it('sets nodes list to state', () => {
store.setNodes(mockNodes);
expect(store.getNodes()).toBe(mockNodes);
});
});
describe('setNodeDetails', () => {
it('sets node details to state', () => {
store.setNodeDetails(2, rawMockNodeDetails);
expect(typeof store.getNodeDetails(2)).toBe('object');
});
});
describe('formatNodeDetails', () => {
it('returns formatted raw node details object', () => {
const nodeDetails = GeoNodesStore.formatNodeDetails(rawMockNodeDetails);
expect(nodeDetails.healthStatus).toBe(rawMockNodeDetails.health_status);
expect(nodeDetails.replicationSlotWAL)
.toBe(rawMockNodeDetails.replication_slots_max_retained_wal_bytes);
});
});
});
import Vue from 'vue';
import stackedProgressBarComponent from '~/vue_shared/components/stacked_progress_bar.vue';
import mountComponent from '../../helpers/vue_mount_component_helper';
const createComponent = (config) => {
const Component = Vue.extend(stackedProgressBarComponent);
const defaultConfig = Object.assign({}, {
successLabel: 'Synced',
failureLabel: 'Failed',
neutralLabel: 'Out of sync',
successCount: 10,
failureCount: 5,
totalCount: 20,
}, config);
return mountComponent(Component, defaultConfig);
};
describe('StackedProgressBarComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('methods', () => {
describe('getPercent', () => {
it('returns percentage from provided count based on `totalCount`', () => {
expect(vm.getPercent(10)).toBe(50);
});
});
describe('barStyle', () => {
it('returns style string based on percentage provided', () => {
expect(vm.barStyle(50)).toBe('width: 50%;');
});
});
describe('getTooltip', () => {
it('returns label string based on label and count provided', () => {
expect(vm.getTooltip('Synced', 10)).toBe('Synced: 10');
});
});
});
describe('template', () => {
it('renders container element', () => {
expect(vm.$el.classList.contains('stacked-progress-bar')).toBeTruthy();
});
it('renders empty state when count is unavailable', () => {
const vmX = createComponent({ totalCount: 0, successCount: 0, failureCount: 0 });
expect(vmX.$el.querySelectorAll('.status-unavailable').length).not.toBe(0);
vmX.$destroy();
});
it('renders bar elements when count is available', () => {
expect(vm.$el.querySelectorAll('.status-green').length).not.toBe(0);
expect(vm.$el.querySelectorAll('.status-neutral').length).not.toBe(0);
expect(vm.$el.querySelectorAll('.status-red').length).not.toBe(0);
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment