Lens app source code (#119)

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>
This commit is contained in:
Jari Kolehmainen authored and GitHub committed 2020-03-15 09:52:02 +02:00
1 parent 936cbd53d4
commit 1d0815abd2
797 files changed
+73714 -215

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
<template>
<div id="app">
<div class="draggable-top" />
<div id="lens-container" />
<div class="main-view" :class="{ 'menu-visible': isMenuVisible }">
<main-menu v-if="isMenuVisible" />
<router-view />
</div>
<bottom-bar v-if="isMenuVisible" />
</div>
</template>
<script>
import MainMenu from "@/components/MainMenu/MainMenu";
import BottomBar from "@/components/BottomBar/BottomBar";
export default {
name: 'Lens',
components: {
BottomBar,
MainMenu
},
computed:{
isMenuVisible: function(){ return this.$store.getters.isMenuVisible }
}
}
</script>
<style>
#lens-container {
position: absolute;
top: 0;
left: 70px;
right: 0;
height: 100%;
z-index: 100;
display: none;
}
</style>
+191
View File
@@ -0,0 +1,191 @@
@import 'custom.scss';
@import '~bootstrap/scss/bootstrap';
@import '~bootstrap-vue/src/index.scss';
@import 'fonts.scss';
html, body{
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background-color: $lens-main-bg;
color: $lens-text-color;
font-size: 14px; // font size that is used also on lens UI
-webkit-font-smoothing: antialiased;
}
body{
font-family: 'Roboto', sans-serif;
}
pre {
color: $lens-text-color-light;
}
#app{
width: 100%;
height: 100%;
& > .main-view{
width: 100%;
height: 100%;
& > .content{
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
overflow: hidden;
}
&.menu-visible > .content{
left: 70px;
bottom: 20px;
}
}
select {
appearance: none;
border: 1px solid $lens-pane-bg;
background-color: $lens-menu-bg;
color: $lens-text-color-light;
background-image: url('data:image/png;base64,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ADC4g/xvHty7AQBuQB2AZD8gug3YCsBqAcBpA2L8gAQ/kC4uyuffUZjl4jzopdoM0CoHBt4nAwwb4NvtgYH+5oGBb/UA5R7Q9mFwlwcAqgSwrgOxcPN113tn8F/xL2AUEjlEqYDhAAAACXBIWXMAAAsTAAALEwEAmpwYAAAF9mlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDAgNzkuMTYwNDUxLCAyMDE3LzA1LzA2LTAxOjA4OjIxICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdEV2dD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlRXZlbnQjIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOCAoTWFjaW50b3NoKSIgeG1wOkNyZWF0ZURhdGU9IjIwMTktMTEtMDVUMTU6NTI6MzMrMDI6MDAiIHhtcDpNZXRhZGF0YURhdGU9IjIwMTktMTEtMDVUMTU6NTI6MzMrMDI6MDAiIHhtcDpNb2RpZnlEYXRlPSIyMDE5LTExLTA1VDE1OjUyOjMzKzAyOjAwIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjUxMTI1YTgyLWY2YmUtNDY1ZC05NDAyLTk0NWNhZGFjYTkzZSIgeG1wTU06RG9jdW1lbnRJRD0iYWRvYmU6ZG9jaWQ6cGhvdG9zaG9wOjJiNjlhZDExLTI0M2MtMzk0Ny05NDM3LWNiZjkzNTM3ODFiYyIgeG1wTU06T3JpZ2luYWxEb2N1bWVudElEPSLine truncated
background-repeat: no-repeat, repeat;
background-position: right .7em top 50%, 0 0;
background-size: .65em auto, 100%;
}
}
.draggable-top{
-webkit-app-region: drag;
left: 0;
top: 0;
position: absolute;
height: 20px;
width: 100%;
}
h1, h2, h3, h4, h5, h6{
color: #fff;
font-size: 20.8px;
font-weight: 300;
}
.table{
color: $lens-text-color-light;
th{
border-top: 1px solid #353a3e;
font-weight: 300;
color: $lens-text-color;
}
td{
border-top: 1px solid #353a3e;
}
tr:first-child{
th, td{
border-top: none;
}
}
}
.card{
background: $lens-pane-bg;
}
.help{
border-left: 1px solid #353a3e;
padding-top: 20px;
&:first-child{
padding-top: 0;
}
h3{
padding: 0.75rem 0 0.75rem 0;
}
}
.tooltip-inner {
font-size: 12px;
}
.editor {
height: 400px;
font-size: 12px;
pre {
background-color: #1E1E1E;
}
}
.aligner-center-center{
height: 100%;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.wrapper{
width:100%;
height:100%;
display:flex;
justify-content:center;
align-items:center;
.error{
text-align: center;
i {
color: #dc3545;
font-size: 100px;
}
}
}
.btn {
line-height: 1.3;
}
// Force BS modals to use main app font etc.
.modal-open{
@extend #app
}
.popover {
background-color: $lens-menu-hl;
select {
appearance: none;
border: 1px solid $lens-pane-bg;
background-color: $lens-menu-bg;
color: $lens-text-color-light;
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAKxWlDQ1BEaXNwbGF5AABIiZWXd1BT+RbHv/emEhJaAgJSQm+CFOkivQSQ3m2EhBJKjCFg7yyuYEVFBCu4KDZcCyBrQSzYFnsvi7LY10VdbKi8P3iEfW/ezJs9M3fuZ75zft9zzm/uH+cCHKZQJssnNYACqUIeFxrAT0lN4zO6QQUTbLhCRSgqlPnHxEQCwND7b0EAH26BAIDr9kKZLB//LDTFmYUigIgBkCEuFBUAxCGAeCOSyRUAZTsAs+kKmQKgnAPAk6ekpgGUBwB42YPcC4CXkZKaBlCpAHjyhLhAgKoLMNlCoTwbYJsD4BeLshUAOwiAo1QskQJsBYBxohyhGGAfAjCqoGCqGGB3AbDO+JtP9n94Zig9hcJsJQ/OAgBgBkkKZfnCmf/wOv5/FOQXDdWwBMDOkYfFAdABiDt5UyOULM2Iih5iiRgY4pyisMQhFhUGpg2xWBgUoTybHxU5xFmSEIHSRyFIGOLMwuD4IZZPjVPWypIH+g+xUD5ctygvUannZAqU/rNyEpKHuFiSFDXEhXnxEcM5gUpdXhSn7D9TGhowXDdEOXtB4d/mlQiUZxU5CWHK2YXD/WdK/Yc9C1OUvYkzg4KHcxKV+TJFgLKWLD9GmZ+ZH6rUC4vjlWcV8oThfEWM8g5zheExQwx/xCMR8eAjBgFwhgfcEQsoMmcoACBwqmymXJKdo+D7y2T5mXyBVOQwiu/s6OQJpKSm8Qc/ifd3QAAgdJjDWr424GkBkDXDWsYroGU5oD4wrFndBNSigLYKUZG8eFCjAgANLKiDBz0YwQzWsIcz3OANPwQjHNFIQComQ4QcFECO6ZiDhShFOVZhHaqxBXXYib04gGYcxUmcxUVcwU3cRxd68Aq9+IB+giAYBIfgEnqEMWFB2BHOhAcxjggmIok4IpVIJ7IJKVFEzCEWE+VEBVFNbCMaiJ+JI8RJ4jxxlbhLPCZeEO+ILySFZJM80pC0JEeTHqQ/GUEmkJPIbHIaOYssIVeQVWQtuYdsIk+SF8mbZBf5iuyjgKJK0aGYUOwpHpRASjQljZJFkVPmUcoolZRayj5KK6WDcp3SRXlN+UylU7lUPtWe6k0NoyZSRdRp1HnUZdRq6k5qE/U09Tr1MbWX+p3GoRnQ7GheNAEthZZNm04rpVXS6mmHaWdoN2k9tA90Ol2HbkV3p4fRU+m59Nn0ZfRN9EZ6G/0qvZvex2Aw9Bh2DB9GNEPIUDBKGRsYexgnGNcYPYxPTFWmMdOZGcJMY0qZi5iVzF3M48xrzGfMfhUNFQsVL5VoFbHKTJWVKttVWlUuq/So9LM0WVYsH1YCK5e1kFXF2sc6w3rAeq+qqmqq6qkaqypRXaBapbpf9ZzqY9XPbC22LTuQPZFdxF7B3sFuY99lv+dwOJYcP04aR8FZwWngnOI84nxS46o5qAnUxGrz1WrUmtSuqb1RV1G3UPdXn6w+S71S/aD6ZfXXGioalhqBGkKNeRo1Gkc0bmv0aXI1nTSjNQs0l2nu0jyv+VyLoWWpFawl1irRqtM6pdXNpXDNuIFcEXcxdzv3DLeHR+dZ8QS8XF45by+vk9erraU9RjtJe4Z2jfYx7S4dio6ljkAnX2elzgGdWzpfRhiO8B+ROWLpiH0jro34qDtS1083U7dMt1H3pu4XPb5esF6e3mq9Zr2H+lR9W/1Y/en6m/XP6L8eyRvpPVI0smzkgZH3DEgDW4M4g9kGdQaXDPoMjQxDDWWGGwxPGb420jHyM8o1Wmt03OiFMdd4nLHEeK3xCeOXfG2+Pz+fX8U/ze81MTAJMyky2WbSadJvamWaaLrItNH0oRnLzMMsy2ytWbtZr7mx+XjzOea7ze9ZqFh4WORYrLfosPhoaWWZbLnEstnyuZWulcBqltVuqwfWHGtf62nWtdY3bOg2HjZ5NptsrtiStq62ObY1tpftSDs3O4ndJruro2ijPEdJR9WOum3Ptve3L7bfbf/YQcch0mGRQ7PDm9Hmo9NGrx7dMfq7o6tjvuN2x/tOWk7hToucWp3eOds6i5xrnG+4cFxCXOa7tLi8HWM3JnPM5jF3XLmu412XuLa7fnNzd5O77XN74W7unu6+0f22B88jxmOZxzlPmmeA53zPo56fvdy8FF4HvP70tvfO897l/Xys1djMsdvHdvuY+gh9tvl0jeOPSx+3dVyXr4mv0LfW94mfmZ/Yr97vmb+Nf67/Hv83AY4B8oDDAR8DvQLnBrYFUYJCg8qCOoO1ghODq4MfhZiGZIfsDukNdQ2dHdoWRguLCFsddltgKBAJGgS94e7hc8NPR7Aj4iOqI55E2kbKI1vHk+PDx68Z/yDKIkoa1RyNaEH0muiHMVYx02J+iaXHxsTWxD6Nc4qbE9cRz42fEr8r/kNCQMLKhPuJ1olFie1J6kkTkxqSPiYHJVckd6WMTpmbcjFVP1WS2pLGSEtKq0/rmxA8Yd2EnomuE0sn3ppkNWnGpPOT9SfnTz42RX2KcMrBdFp6cvqu9K/CaGGtsC9DkLExo1cUKFoveiX2E68Vv8j0yazIfJblk1WR9TzbJ3tN9osc35zKnNeSQEm15G1uWO6W3I950Xk78gbyk/MbC5gF6QVHpFrSPOnpqUZTZ0y9KrOTlcq6pnlNWzetVx4hry8kCicVtih4CpniUpF10Q9Fj4vHFdcUf5qeNP3gDM0Z0hmXZtrOXDrz2ayQWT/Nps4WzW6fYzJn4ZzHc/3nbptHzMuY1z7fbH7J/J4FoQt2LmQtzFv46yLHRRWL/lqcvLi1xLBkQUn3D6E/7C5VK5WX3l7ivWTLj9QfJT92LnVZumHp9zJx2YVyx/LK8q/LRMsuLHdaXrV8YEXWis6Vbis3r6Kvkq66tdp39c4KzYpZFd1rxq9pWstfW7b2r3VT1p2vHFO5ZT1rfdH6rqrIqpYN5htWbfhanVN9syagpnGjwcalGz9uEm+6ttlv874thlvKt3zZKtl6Z1votqZay9rKOnpdcd3T7UnbO37y+KmhXr++vP7bDumOrp1xO083uDc07DLYtXI3ubto94s9E/dc2Ru0t2Wf/b5tjTqN5fuxv2j/y5/Tf751IOJA+0GPg/sOWRzaeJh7uKyJaJrZ1Nuc09zVktpy9Uj4kfZW79bDvzj8suOoydGaY9rHVh5nHS85PnBi1om+Nlnb65PZJ7vbp7TfP5Vy6sbp2NOdZyLOnDsbcvZUh3/HiXM+546e9zp/5ILHheaLbhebLrleOvyr66+HO906my67X2654nml9erYq8ev+V47eT3o+tkbghsXb0bdvHor8dad2xNvd90R33l+N//u23vF9/rvL3hAe1D2UONh5SODR7W/2fzW2OXWdexx0ONLT+Kf3O8Wdb/6vfD3rz0lTzlPK58ZP2t47vz86IuQF1deTnjZ80r2qv916R+af2x8Y/3m0J9+f17qTenteSt/O/Bu2Xu99zv+GvNXe19M36MPBR/6P5Z90vu087PH544vyV+e9U//yvha9c3mW+v3iO8PBgoGBmRCuRAAQAFAZmUB73YAnFSAewVgTRjcqQEAxOB/ADC4g/xvHty7AQBuQB2AZD8gug3YCsBqAcBpA2L8gAQ/kC4uyuffUZjl4jzopdoM0CoHBt4nAwwb4NvtgYH+5oGBb/UA5R7Q9mFwlwcAqgSwrgOxcPN113tn8F/xL2AUEjlEqYDhAAAACXBIWXMAAAsTAAALEwEAmpwYAAAF9mlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDAgNzkuMTYwNDUxLCAyMDE3LzA1LzA2LTAxOjA4OjIxICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdEV2dD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlRXZlbnQjIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOCAoTWFjaW50b3NoKSIgeG1wOkNyZWF0ZURhdGU9IjIwMTktMTEtMDVUMTU6NTI6MzMrMDI6MDAiIHhtcDpNZXRhZGF0YURhdGU9IjIwMTktMTEtMDVUMTU6NTI6MzMrMDI6MDAiIHhtcDpNb2RpZnlEYXRlPSIyMDE5LTExLTA1VDE1OjUyOjMzKzAyOjAwIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjUxMTI1YTgyLWY2YmUtNDY1ZC05NDAyLTk0NWNhZGFjYTkzZSIgeG1wTU06RG9jdW1lbnRJRD0iYWRvYmU6ZG9jaWQ6cGhvdG9zaG9wOjJiNjlhZDExLTI0M2MtMzk0Ny05NDM3LWNiZjkzNTM3ODFiYyIgeG1wTU06T3JpZ2luYWxEb2N1bWVudElEPSLine truncated
background-repeat: no-repeat, repeat;
background-position: right .7em top 50%, 0 0;
background-size: .65em auto, 100%;
}
.arrow:after {
border-top-color: $lens-menu-hl;
}
}
.popover-body {
color: $lens-text-color;
li.list-group-item {
background-color: inherit;
padding: 0.5rem 0.75rem;
a {
color: var(--lens-text-color-light);
}
}
}
.popover-header{
color: $lens-text-color;
background-color: $lens-main-bg;
border-bottom: 0px;
i.material-icons {
position: relative;
top: 3px;
font-size: 16px;
}
}
+72
View File
@@ -0,0 +1,72 @@
// from Kontena Lens UI
$lens-main-bg: #1e2124 !default; // dark bg
$lens-pane-bg: #262b2f !default; // all panels main bg
$lens-dock-bg: #2E3136 !default; // terminal and top menu bar
$lens-menu-bg: #36393E !default; // sidemenu on left
$lens-menu-hl: #414448 !default; // sidemenu on left, top left corner
$lens-text-color: #87909c !default;
$lens-text-color-light: #a0a0a0 !default;
$kontena-blue: #3d90ce !default;
// export as css variables
* {
--lens-main-bg: #{$lens-main-bg}; // dark bg
--lens-pane-bg: #{$lens-pane-bg}; // all panels main bg
--lens-dock-bg: #{$lens-dock-bg}; // terminal and top menu bar
--lens-menu-bg: #{$lens-menu-bg}; // sidemenu on left
--lens-menu-hl: #{$lens-menu-hl}; // sidemenu on left, top left corner
--lens-text-color: #{$lens-text-color};
--lens-text-color-light: #{$lens-text-color-light};
--kontena-blue: #{$kontena-blue};
}
// Base grayscale colors definitions
$white: #fff !default;
$gray-100: #f8f9fa !default;
$gray-200: #e9ecef !default;
$gray-300: #dee2e6 !default;
$gray-400: #ced4da !default;
$gray-500: #adb5bd !default;
$gray-600: #6c757d !default;
$gray-700: #495057 !default;
$gray-800: #343a40 !default;
$gray-900: #1e2124 !default;
$black: #000 !default;
// Base colors definitions
$blue: #3d90ce !default;
$indigo: #6610f2 !default;
$purple: #6f42c1 !default;
$pink: #e83e8c !default;
$red: #CE3933 !default;
$orange: #fd7e14 !default;
$yellow: #ffc107 !default;
$green: #4caf50 !default;
$teal: #20c997 !default;
$cyan: #6ca5b7 !default;
// Theme color default definitions
$primary: $kontena-blue !default;
$secondary: $gray-600 !default;
$success: $green !default;
$info: $cyan !default;
$warning: $yellow !default;
$danger: $red !default;
$light: $gray-100 !default;
$dark: $gray-800 !default;
// This table defines the theme colors (variant names)
$theme-colors: () !default;
$theme-colors: map-merge(
(
'primary': $primary,
'secondary': $secondary,
'success': $success,
'info': $info,
'warning': $warning,
'danger': $danger,
'light': $light,
'dark': $dark
),
$theme-colors
);
+2
View File
@@ -0,0 +1,2 @@
@import "~typeface-roboto/index.css";
@import "~material-design-icons/iconfont/material-icons.css";
+182
View File
@@ -0,0 +1,182 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 21.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 1303.8 737.4" style="enable-background:new 0 0 1303.8 737.4;" xml:space="preserve">
<style type="text/css">
.st0{clip-path:url(#SVGID_2_);}
.st1{fill:#393C40;}
.st2{fill:none;stroke:#393C40;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:9.54,9.54;}
.st3{fill:none;stroke:#393C40;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:10.35,10.35;}
.st4{fill:none;stroke:#393C40;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:9.06,9.06;}
.st5{fill:none;stroke:#393C40;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:12.67,12.67;}
.st6{fill:none;stroke:#393C40;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:12.81,12.81;}
.st7{fill:none;stroke:#393C40;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:10.4,10.4;}
.st8{fill:none;stroke:#393C40;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:9.25,9.25;}
.st9{fill:none;stroke:#393C40;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:9.55,9.55;}
.st10{fill:none;stroke:#393C40;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:9.66,9.66;}
.st11{fill:none;stroke:#393C40;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:10.37,10.37;}
.st12{fill:none;stroke:#393C40;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:10,10;}
.st13{fill:none;stroke:#393C40;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:11.37,11.37;}
.st14{clip-path:url(#SVGID_4_);}
.st15{fill:#2E3136;}
.st16{clip-path:url(#SVGID_6_);}
.st17{clip-path:url(#SVGID_8_);}
</style>
<title>Artboard 1</title>
<g>
<defs>
<rect id="SVGID_1_" width="1303.8" height="737.4"/>
</defs>
<clipPath id="SVGID_2_">
<use xlink:href="#SVGID_1_" style="overflow:visible;"/>
</clipPath>
<g class="st0">
<path class="st1" d="M715.5,222.6c0.2-6.2-4-12.2-9.6-15V190c-2.1,1.9-4.9,3-7.8,3c-1.8,0-3.6-0.5-5.2-1.4v15.9
c-5.6,2.6-9.3,8.2-9.6,14.4c-0.1,2.7,0.6,5.5,1.8,7.9c-0.1-0.8-0.2-1.7-0.2-2.5c0.2-7.8,7.1-14.5,14.7-14.2s14.4,7.4,14.2,14.9
c0,0.4,0,0.8-0.1,1.2C714.8,227.2,715.4,225,715.5,222.6"/>
</g>
</g>
<line class="st2" x1="870.3" y1="376.4" x2="870.3" y2="519.5"/>
<line class="st3" x1="936.8" y1="407.1" x2="936.8" y2="562.4"/>
<line class="st4" x1="1034.7" y1="581.8" x2="1034.7" y2="627.1"/>
<line class="st5" x1="967.1" y1="547" x2="967.1" y2="585"/>
<line class="st6" x1="1104.2" y1="542.4" x2="1104.2" y2="580.8"/>
<line class="st7" x1="1036.7" y1="279.6" x2="1036.7" y2="435.6"/>
<line class="st8" x1="1106.2" y1="240.8" x2="1106.2" y2="361"/>
<line class="st9" x1="1204" y1="436.5" x2="1204" y2="522.4"/>
<line class="st10" x1="1134.5" y1="475.5" x2="1134.5" y2="562.4"/>
<line class="st11" x1="1064.9" y1="436.5" x2="1064.9" y2="467.6"/>
<line class="st12" x1="1006.4" y1="370.6" x2="1006.4" y2="460.6"/>
<line class="st13" x1="965.1" y1="240.8" x2="965.1" y2="297.6"/>
<g>
<defs>
<rect id="SVGID_3_" width="1303.8" height="737.4"/>
</defs>
<clipPath id="SVGID_4_">
<use xlink:href="#SVGID_3_" style="overflow:visible;"/>
</clipPath>
<g class="st14">
<path class="st15" d="M773.7,590.9c-8.2-4.8-8.2-12.5,0-17.3L823,545c8.2-4.8,21.7-4.8,30,0l49.4,28.6c8.2,4.8,8.2,12.5,0,17.3
L853,619.5c-8.2,4.8-21.7,4.8-30,0L773.7,590.9z"/>
<path class="st15" d="M921.8,676.7c8.2,4.8,21.7,4.8,30,0l49.4-28.6c8.2-4.8,8.2-12.5,0-17.3l-49.4-28.6c-8.2-4.8-21.7-4.8-30,0
l-49.4,28.6c-8.2,4.8-8.2,12.5,0,17.3L921.8,676.7z"/>
<path class="st15" d="M1020.7,733.8c8.2,4.8,21.7,4.8,30,0l49.3-28.6c8.2-4.8,8.2-12.5,0-17.3l-49.4-28.6c-8.2-4.8-21.7-4.8-30,0
l-49.4,28.6c-8.2,4.8-8.2,12.5,0,17.3L1020.7,733.8z"/>
<path class="st15" d="M1119.5,676.7c8.2,4.8,21.7,4.8,30,0l49.4-28.6c8.2-4.8,8.2-12.5,0-17.3l-49.4-28.6c-8.2-4.8-21.7-4.8-30,0
l-49.4,28.6c-8.2,4.8-8.2,12.5,0,17.3L1119.5,676.7z"/>
<path class="st15" d="M1248.3,545.1c-8.2-4.8-21.7-4.8-30,0l-49.4,28.6c-8.2,4.8-8.2,12.5,0,17.3l49.4,28.6c8.2,4.8,21.7,4.8,30,0
l49.4-28.5c8.2-4.8,8.2-12.5,0-17.3L1248.3,545.1z"/>
<path class="st15" d="M942.8,369.3c-3.5,2.3-5.7,6-5.9,10.2V391c0,3.7-2.7,5.2-6,3.4l-51.6-29.2c-3.5-2.2-5.7-6-6-10.1v-22.7
l1.1-0.6c0.6-0.3,3.8,0.9,7,2.7l48.6,27.3c3.7,1.8,8.1,1.8,11.9,0l49.1-28.3c3.3-1.9,6.7-3.1,7.7-2.8c1.1,0.5,1.8,1.6,1.8,2.8
c0,1.2-2.6,3.7-5.9,5.6L942.8,369.3z M942.8,292.3c-3.7-1.9-8.1-1.9-11.8,0l-58.7,33.9c-3.5,2.2-5.7,6-5.9,10.1v22.9
c0.2,4.1,2.5,7.9,6,10.1l58.7,33.2c3.7,1.9,8.1,1.9,11.9,0l58.7-33.2c3.5-2.2,5.7-6,6-10.1v-22.8c-0.2-4.1-2.4-7.9-5.9-10.1
L942.8,292.3z"/>
<path class="st15" d="M1140.4,437.4c-3.5,2.3-5.7,6-5.9,10.2v11.6c0,3.7-2.7,5.2-5.9,3.4l-51.6-29.2c-3.5-2.2-5.7-6-5.9-10.1
v-22.8l1.1-0.6c0.6-0.3,3.8,0.9,7,2.7l48.5,27.3c3.7,1.9,8.1,1.9,11.9,0l49.2-28.3c3.3-1.9,6.7-3.1,7.7-2.8
c1.1,0.5,1.8,1.6,1.8,2.8c0,1.2-2.6,3.7-5.9,5.6L1140.4,437.4z M1140.4,360.4c-3.7-1.9-8.1-1.9-11.8,0l-58.7,33.9
c-3.5,2.2-5.7,6-5.9,10.1v22.9c0.2,4.1,2.5,7.9,5.9,10.1l58.7,33.2c3.7,1.9,8.1,1.9,11.9,0l58.7-33.2c3.5-2.2,5.7-6,5.9-10.1
v-22.9c-0.2-4.1-2.4-7.9-5.9-10.1L1140.4,360.4z"/>
<path class="st15" d="M1041.6,241c-3.5,2.3-5.7,6-5.9,10.2v11.6c0,3.7-2.7,5.2-5.9,3.4L978.1,237c-3.5-2.2-5.7-6-6-10.1v-22.8
l1.1-0.6c0.6-0.3,3.8,0.9,7,2.7l48.5,27.3c3.7,1.9,8.1,1.9,11.9,0l49.2-28.3c3.3-1.9,6.7-3.1,7.7-2.8c1.1,0.5,1.8,1.6,1.8,2.8
c0,1.2-2.6,3.7-5.9,5.6L1041.6,241z M1041.6,164c-3.7-1.9-8.1-1.9-11.8,0L971,197.9c-3.5,2.2-5.7,6-5.9,10.1v22.8
c0.2,4.1,2.5,7.9,6,10.1l58.7,33.2c3.7,1.9,8.1,1.9,11.9,0l58.7-33.2c3.5-2.2,5.7-6,5.9-10.1v-22.8c-0.2-4.1-2.4-7.9-5.9-10.1
L1041.6,164z"/>
<path class="st15" d="M1041.6,538.7c-3.5,2.3-5.7,6-5.9,10.2v11.6c0,3.7-2.7,5.2-5.9,3.4l-51.6-29.2c-3.5-2.2-5.7-6-6-10.1v-22.8
l1.1-0.6c0.6-0.3,3.8,0.9,7,2.7l48.5,27.3c3.7,1.8,8.1,1.8,11.9,0l49.2-28.3c3.3-1.9,6.7-3.1,7.7-2.8c1.1,0.5,1.8,1.6,1.8,2.8
c0,1.2-2.6,3.7-5.9,5.6L1041.6,538.7z M1041.6,461.7c-3.7-1.9-8.1-1.9-11.8,0L971,495.6c-3.5,2.2-5.7,6-5.9,10.1v22.9
c0.2,4.1,2.5,7.9,6,10.1l58.7,33.2c3.7,1.9,8.1,1.9,11.9,0l58.7-33.2c3.5-2.2,5.7-6,5.9-10.1v-22.9c-0.2-4.1-2.4-7.9-5.9-10.1
L1041.6,461.7z"/>
<path class="st1" d="M582.5,50.3l-0.1-0.1L582.5,50.3 M396.4,166.1c-6.8,6.8-6.1,10.1,2.8,13.7c10.5,4.3,17.5,12,21.7,22.5
c2.9,7.2,6.9,8.1,12.4,2.6c16-15.9,26.1-26,42.2-41.8c3.9-4,8.2-9.3,8.9-16c1.4-10.3,5-17,9-21c19.1-19,27.1-27.7,40.5-41.3
c1.6-1.7,2.5-4.1,2.4-6.5c-1.6-10.5,1.7-18.2,10.5-23.3c8.2-4.7,16.9-4,24.6,2.1c0.3,0.2,1.7,1.7,1.8,1.9c0,0,0.2,0.1,4.4-4.1
l4.8-4.8c-3.1-3.1-22.3-22.2-24.7-24.4c-4.9-4.7-3.9-4.7-4.9-3.7L527,47.2c0,0-3.8,3.8-9.6-1.9C517.8,45,436.3,126.2,396.4,166.1"
/>
<path class="st1" d="M180.6,384c0,0.3,0,0.6,0,0.9C180.6,384.5,180.6,384.2,180.6,384 M362.3,256.2c-10.5-4.3-17.5-12-21.7-22.5
c-2.9-7.2-6.9-8.1-12.4-2.6c-16,15.9-31.8,32-47.9,47.8c-3.2,3.2-4.9,6.3-4,10.7c1.8,8.8-1.6,15.1-8.1,21.2
c-12,11.2-22.9,22.5-34.4,34.3c11.9,4,22.6,15.8,27,29l104.2-104.2C372,263,371.2,259.8,362.3,256.2"/>
<path class="st1" d="M616.5,60.2c-0.2,0.2-0.3,0.4-0.3,0.6C616.2,60.6,616.3,60.4,616.5,60.2 M616.2,60.9c0,0,0,0.1,0,0.1
C616.1,61,616.1,60.9,616.2,60.9 M616.5,61.6c0.1,0.1,0.1,0.1,0.2,0.2C616.6,61.7,616.5,61.7,616.5,61.6 M626.9,61.2
c-0.1-3.3-2.6-6-5.9-6.2c-3.3,0-6,2.6-6.1,5.9c-0.1,3.3,2.5,6,5.8,6.2C624,67,626.7,64.5,626.9,61.2"/>
<path class="st1" d="M575.9,72.3c-0.1-10.5-8.7-18.9-19.2-18.8s-18.9,8.7-18.8,19.2c0,10.5,9,19.4,19.2,19.2
C567.8,91.7,576.1,82.9,575.9,72.3"/>
<path class="st1" d="M188.9,482h-53c-12,0-12-12-12-12v-71c0,82,65,82.8,65,82.8C200.9,482,188.9,482,188.9,482 M223.2,483v-46
c0-6-6-6.3-6-6.3c-11.7-0.9-22.6-6.1-30.7-14.5c-10-10.7-11.2-24.1-10.8-32.4c0.3-6-5.7-6-5.7-6h-47c-6,0-6,6-6,6V483c0,6,6,6,6,6
h94.2C223.2,489,223.2,483,223.2,483"/>
<path class="st1" d="M2.2,363.7c0,0-0.6-1.2-0.8-1.1l-1,0.4c0,0-0.3,0.1-0.3-5.9v-24.4c0,0,0-6,2.4-6s11.8-0.4,16.2-0.4
c11,0,13.1,2.1,13.1,13.2c0,12.6,0,25.2,0,32.2c0,0,0,6,3.8,5.7s4.8,3.7,4.8,9.5c-0.2,27.7-0.2,55.4,0,83.1c0,6.2,0.2,12-4.2,11.8
c0,0-4.4-0.1-4.4,5.9c0,7.9,0.1,21.2,0,34.5c0,6.5-2.9,10.1-8.9,10.4s-16.8,0.4-19.9,0.4c0,0-3.1,0-3.1-6v-24.6c0,0,0-6,0.3-5.9
l1.2,0.3c0,0,0.3,0.1,0.8-1.1s2.5-3.7,2.5-6.1c0.2-40,0.2-79.9,0-119.9C5,367.5,2.9,365,2.2,363.7"/>
<path class="st1" d="M114.9,469.9l-1.5,1.2c0,0-0.3,0.2-0.8-0.5s-2.3-2.1-2.3-3.5c-0.2-23.2-0.2-46.4,0-69.6c0-1.3,2-2.6,2.5-3.2
c0,0,0.5-0.6,0.8-0.5l1.3,0.9 M44.9,432.6c0-15.2,0.1-30.5,0-45.8c0-5.4,2.1-7.9,7.6-7.8c10.5,0.2,37.1,0.1,47.6,0
c5.4-0.1,7.9,2.2,7.9,7.6c-0.1,30.5-0.1,61,0,91.5c0,5.3-2.2,8-7.7,7.9c-10.3-0.1-36.7-0.1-47,0c-5.8,0.1-8.6-2.3-8.5-8.4
C45,462.6,44.8,447.6,44.9,432.6"/>
<path class="st1" d="M218.4,427.1c-24.7-0.2-40.3-21.7-39.5-41.9c1-24.1,19.7-40.8,43.9-39.6c21.8,1.1,38.3,20.9,37,44.4
C258.6,410.9,239.4,428.2,218.4,427.1"/>
<path class="st15" d="M692.1,363.5c4.6,2.3,10,2.2,14.5-0.1l60.3-34.7c4-2.3,8.2-3.8,9.4-3.4c1.1,0.5,1.9,1.5,2.2,2.7
c0,0,0,0.7,0,1.4s-3.2,3.8-7.2,6.1l-63.5,37.1c-4,2.3-7.2,7.9-7.2,12.5v14.2c0,4.5-3.3,6.4-7.3,4.1L630,367.7
c-4-2.3-7.3-7.8-7.3-12.4v-27.9c0,0,0,0,0.3-0.2l1-0.6c0.7-0.4,4.6,1.1,8.6,3.3L692.1,363.5z M693.3,278.3l-72,41.5
c-4.1,2.7-6.7,7.1-7.2,11.9l1.7,0.2c1.2,0.2,2,1.3,1.9,2.5v8c0,0,0,1.1-3.7,2.8v14.9c0,4.5,3.3,10.1,7.3,12.4l71.9,40.7
c4.6,2.3,10,2.3,14.6,0l71.9-40.7c4-2.3,7.3-7.8,7.3-12.4v-12c-0.3-0.1-0.6-0.1-0.8-0.2c-8.1-2.7-8.1-4.6-8.1-4.6v-8
c-0.1-1.2,0.7-2.3,1.9-2.5l7-1c-0.4-4.9-3.1-9.4-7.2-12.1l-72-41.5C703.2,276,697.9,276,693.3,278.3"/>
<path class="st1" d="M487.8,147L487.8,147l35.2-36.8l-5.1-4.9l-23,23.7C494.9,129,488.9,135,487.8,147"/>
<path class="st1" d="M519.9,43.1L519.9,43.1L563.6,0l5.3,5.2l-14,14.4l-1.3-1.3l-27.2,26.1C526.4,44.5,523.8,47.1,519.9,43.1"/>
<path class="st1" d="M631.5,61.3l-7.1,7.1c0,0-4.2,4.2,0,8.5l62.5,62.6c0,0,4.2,4.2,8.5,0l2.8-2.8c0,0,4.2-4.2,0-8.5L631.5,61.3z"
/>
<path class="st1" d="M709.3,166.1L594.9,51.7c0,0-4.2-4.2-8.5,0l-2.8,2.8c0,0-4.2,4.2,0,8.5l24.1,24.1l-11.6,11.6
c0,0-4.2,4.2,0,8.5l86,86c0,0,4.2,4.2,8.5,0l1.9-1.9c-3.5-2.2-5.6-6-5.5-10.1c0.2-6.2,5.7-11.5,11.6-11.2c3.9,0.3,7.4,2.4,9.5,5.7
l1.2-1.2C709.3,174.5,713.5,170.3,709.3,166.1 M690.2,182.8c-0.1-0.1-0.2-0.2-0.3-0.3L690.2,182.8 M690.4,183l-0.1-0.1L690.4,183
M689.4,181c0.1-0.4,0.3-0.8,0.7-1.1C689.7,180.2,689.4,180.6,689.4,181"/>
<path class="st1" d="M708.4,138.2c0,0-4.2-4.2-8.5,0L697,141c0,0-4.2,4.2,0,8.5l14,13.9L708.4,138.2z"/>
<path class="st1" d="M693,228.7c0.1-0.3,0.2-0.6,0.5-0.8C693.2,228.1,693.1,228.4,693,228.7 M693.7,230c-0.1-0.1-0.2-0.2-0.3-0.3
C693.5,229.8,693.6,229.9,693.7,230 M707.3,229.2c-0.3,4.3-3.9,7.7-8.2,7.8c-4.4-0.2-7.8-3.8-7.7-8.2c0.2-4.4,3.7-7.8,8.1-7.8
C703.8,221.3,707.2,224.9,707.3,229.2 M690.5,227.4c-0.3,0.3-0.6,0.7-0.7,1.1C689.9,228,690.2,227.6,690.5,227.4 M724.9,239v-3
c0,0,0-5-5-5h-8.8c0.1-0.5,0.2-1.1,0.2-1.6c0.2-6.2-5.4-12.1-11.7-12.4s-12,5.3-12.2,11.7c0,0.8,0,1.5,0.2,2.3h-8.7c0,0-5,0-5,5v3
c0,0,0,5,5,5h41C719.9,244,724.9,244,724.9,239"/>
<path class="st1" d="M707.7,181.8c-0.1-5-4-9.1-9-9.5c-4.8-0.2-9.2,4.1-9.4,9c-0.1,5.1,3.8,9.3,8.9,9.4
C703.2,190.6,707.4,186.7,707.7,181.8"/>
</g>
</g>
<polygon class="st1" points="570.8,7.1 570.8,7.1 568.9,9 615.4,55.5 617.3,53.6 "/>
<g>
<defs>
<rect id="SVGID_5_" width="1303.8" height="737.4"/>
</defs>
<clipPath id="SVGID_6_">
<use xlink:href="#SVGID_5_" style="overflow:visible;"/>
</clipPath>
<g class="st16">
<path class="st1" d="M586.1,44.3l-25.1-25.1c-0.4-0.4-1-0.4-1.4,0c0,0-0.1,0.1-0.1,0.1l-1.8,1.8c-0.1,0.1-0.1,0.2-0.2,0.3
L583.9,48l0.1-0.1l0,0c0.1-0.1,0.2-0.1,0.3-0.2l1.7-1.9C586,45.8,586.8,45,586.1,44.3"/>
</g>
</g>
<polygon class="st1" points="540.7,88.9 540.7,88.9 538.8,87 521,105.1 522.9,107 "/>
<g>
<defs>
<rect id="SVGID_7_" width="1303.8" height="737.4"/>
</defs>
<clipPath id="SVGID_8_">
<use xlink:href="#SVGID_7_" style="overflow:visible;"/>
</clipPath>
<g class="st17">
<path class="st1" d="M1123.8,193.5c0.3,0.3,0.1,0.5-0.2,0.7c-2.5,1.6-5,3.1-7.4,4.8c-2,1.3-3.6,3-4.7,5.1
c-0.8,1.6-0.7,3.6,0.3,5.2c1.3,2,3,3.7,5.1,5c2.2,1.5,4.5,2.9,6.7,4.3c0.3,0.2,0.5,0.3,0.2,0.6c-1.7-0.9-3.4-1.9-5.1-2.7
c-2.1-1.1-4.3-2-6.6-2.6c-1.8-0.6-3.7-0.5-5.4,0.2c-1.3,0.7-2.4,2-2.8,3.4c-0.7,1.8-1.1,3.6-1.2,5.5c-0.3,2.8-0.4,5.7-0.3,8.5
c0,0.1,0,0.2,0,0.2h-0.6c0-0.2,0-0.3,0-0.5c-0.1-2.3-0.1-4.6-0.3-6.9c-0.1-2.5-0.6-4.9-1.5-7.2c-0.6-1.9-2.2-3.3-4.2-3.6
c-1.4-0.2-2.8-0.1-4.1,0.4c-1.8,0.6-3.5,1.2-5.2,2c-2.1,1-4.2,2.1-6.3,3.2l-0.2-0.3c0.1-0.1,0.3-0.2,0.4-0.3
c2.5-1.6,5-3.1,7.4-4.9c1.9-1.3,3.5-3,4.6-5c0.9-1.7,0.7-3.7-0.3-5.2c-1.3-2-3-3.7-5-4.9c-2.2-1.5-4.5-2.9-6.7-4.3
c-0.3-0.2-0.5-0.3-0.2-0.6c1.8,1,3.6,2,5.4,2.9c2.2,1.1,4.4,2,6.8,2.6c1.3,0.3,2.6,0.4,3.9,0.1c1.7-0.5,3-1.7,3.6-3.3
c0.8-1.9,1.3-4,1.4-6.1c0.2-2.3,0.2-4.6,0.4-6.9c0-0.5,0.1-1,0.1-1.5c0-0.1,0.2-0.2,0.3-0.3l0.2,0.1c0,0.4,0,0.9,0,1.3
c0,1.5,0,3,0.1,4.5c0.1,2.9,0.6,5.7,1.5,8.4c0.8,2,2,3.5,4.2,3.9c1.6,0.2,3.2,0.1,4.7-0.4c3.5-1.2,6.8-2.7,10-4.6L1123.8,193.5"/>
<path class="st1" d="M654.9,259c-9,0-14-0.2-22,0c-5,0.1-17,0-17.8,1.1c-4.3,6.1-7.5,14.1-8,21.6c-1.1,17-0.4,34.2-0.4,51.8l8,1.1
v8.6c-15.6,5.2-21.9-3.5-21.7-17.2c0.2-15.7-0.2-31.4,0.2-47.1c0.1-6.4,1.3-12.7,2.1-19.5c-7.4-0.6-5.1-6.6-4.5-12.4h89V259"/>
<path class="st1" d="M731.9,259c9,0,27-0.2,35,0c5,0.1,12.2-0.6,14,2c4.3,6.2,7.5,13.4,8,20.8c1.1,17,0.4,34.2,0.4,51.8l-8,1.1
v8.6c15.6,5.2,21.9-3.5,21.7-17.2c-0.2-15.7,0.2-31.4-0.2-47.1c-0.1-6.4-1.3-12.7-2.1-19.5c7.4-0.6,5.1-6.6,4.5-12.4h-89v12"/>
<path class="st1" d="M275.1,286c-7.5,7.5-44.8,44.5-58.3,57.9c2.7-0.3,5.3-0.4,8-0.3c2.1,0.1,4.1,0.4,6.1,0.8
c3.1-3.2,7-7.3,12.1-12.5c8.1-8.3,15.8-16.3,24.2-24.2c4.6-4.3,7-8.7,5.8-15C272.5,290.3,273.2,287.7,275.1,286 M275.8,285.2
c-0.2,0.2-0.5,0.5-0.7,0.8C276.5,284.6,276.9,284.2,275.8,285.2"/>
<path class="st1" d="M390.6,217c0,5.9-4.8,10.6-10.7,10.6c-5.9,0-10.6-4.8-10.6-10.7c0-5.8,4.7-10.5,10.4-10.6
C385.7,206.3,390.5,211.1,390.6,217 M419,217c0-21.6-17.5-39.1-39.1-39.1s-39.1,17.5-39.1,39.1c0,21.6,17.5,39.1,39.1,39.1
S419,238.6,419,217L419,217"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 497.43 228.97"><defs><style>.cls-1{fill:#3f90ce;}.cls-2{opacity:0.15;}.cls-3{fill:#fff;}</style></defs><title>Asset 2</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M0,0H12.25V170H110v11.64H0Z"/><path class="cls-1" d="M138.75,167.24Q122.82,148,122.82,116.39t15.93-50.84q15.93-19,42.27-19T223.29,64q15.92,17.46,15.93,46.55v5.82H135.08q0,26.65,12.55,42.58T181,174.9a50.14,50.14,0,0,0,28.18-8.27A44.23,44.23,0,0,0,227,144.27h12.25q-3.08,18.07-19.61,30T181,186.23Q154.68,186.23,138.75,167.24ZM227,104.75v-4.59a46.49,46.49,0,0,0-14.71-30.63A45.05,45.05,0,0,0,181,57.28a43.4,43.4,0,0,0-30.94,12.56,47.58,47.58,0,0,0-15,31.54v3.37Z"/><path class="cls-1" d="M268,181.64V51.15h12.25V73.21a41.83,41.83,0,0,1,16.85-18.38,46.6,46.6,0,0,1,25.12-7q19.29,0,32.77,12.56Q368.79,73.21,368.78,91v90.67H356.53V91a30.37,30.37,0,0,0-10.11-23,34.12,34.12,0,0,0-24.19-9.5A40.35,40.35,0,0,0,295,68.92a45.21,45.21,0,0,0-14.7,26.65v86.07Z"/><path class="cls-1" d="M409.83,172.75Q395.12,160.51,395.12,143h11.34q0,13.17,11.33,22.67,11.32,9.18,27.26,9.19,18.38,0,29.71-8.27a27,27,0,0,0,11-22.36q0-10.41-11.64-17.77-11.64-7-31.24-8.88-20.84-1.84-33.08-11.64-12.57-9.8-12.56-24.2,0-16.23,12.56-25.11t35.22-8.89q21.75,0,34.61,9.5,13.17,9.5,13.17,25.42H481.19A28.06,28.06,0,0,0,469.55,64q-9.49-6.73-24.5-6.74-16.55,0-26.34,6.74t-9.8,17.76q0,10.43,11.94,17.77,11.64,7,31.25,8.88,20.5,1.84,33.08,12.25,12.24,10.42,12.25,26,0,17.46-14.4,27.87Q468.95,185,445.05,185,424.22,185,409.83,172.75Z"/><g class="cls-2"><polygon class="cls-3" points="433.45 208.56 430.83 208.56 426.32 215.64 426.32 208.55 423.85 208.55 423.85 222.42 426.32 222.42 426.32 219.42 427.97 216.89 430.93 222.42 433.75 222.42 429.35 214.97 433.45 208.56"/><path class="cls-3" d="M441.17,208.56h-3.72a2.26,2.26,0,0,0-2.47,2v9.9a2.26,2.26,0,0,0,2.47,2h3.72a2.28,2.28,0,0,0,2.47-2v-9.9A2.26,2.26,0,0,0,441.17,208.56Zm0,11.88h-3.72v-9.9h3.72Z"/><polygon class="cls-3" points="452.29 216.7 448.79 208.55 446.11 208.55 446.11 222.42 448.59 222.42 448.59 213.34 452.58 222.42 454.77 222.42 454.77 208.55 452.29 208.55 452.29 216.7"/><polygon class="cls-3" points="456.63 210.54 459.72 210.54 459.72 222.42 462.2 222.42 462.2 210.54 465.28 210.54 465.28 208.55 456.63 208.55 456.63 210.54"/><polygon class="cls-3" points="467.14 222.42 474.56 222.42 474.56 220.44 469.61 220.44 469.61 216.08 473.32 216.08 473.32 214.1 469.61 214.1 469.61 210.53 474.56 210.53 474.56 208.55 467.14 208.55 467.14 222.42"/><polygon class="cls-3" points="482.89 208.55 482.89 216.7 479.38 208.55 476.71 208.55 476.71 222.42 479.18 222.42 479.18 213.34 483.18 222.42 485.36 222.42 485.36 208.55 482.89 208.55"/><path class="cls-3" d="M493.72,208.55h-3.11l-3.71,13.87h2.62l.49-2h4.31l.49,2h2.62Zm-3.21,9.91,1.7-6.45,1.61,6.45Z"/><polygon class="cls-3" points="405.98 201.77 402.06 204.04 398.13 206.31 394.2 208.57 394.2 213.11 394.2 217.64 394.2 222.17 398.13 224.44 398.13 219.91 398.13 215.37 398.13 210.84 402.06 208.57 402.06 213.11 402.06 213.11 402.06 217.64 405.98 215.37 405.98 215.37 409.91 213.11 409.91 208.57 413.83 206.31 409.91 204.04 405.98 201.77"/><polygon class="cls-3" points="413.83 210.84 413.83 215.37 409.91 217.64 413.83 219.91 413.83 224.44 417.76 222.17 417.76 217.64 417.76 213.11 417.76 208.57 413.83 206.31 413.83 210.84"/><polygon class="cls-3" points="405.98 219.91 402.06 222.17 402.06 222.17 402.06 226.71 405.98 228.97 409.91 226.71 409.91 222.17 405.98 219.91 405.98 219.91"/></g><g class="cls-2"><path class="cls-3" d="M374.77,219.07a3.08,3.08,0,0,1-1.11-1.28v1.5h-.72V208.56h.72v4.76a2.82,2.82,0,0,1,2.7-1.86,3,3,0,0,1,2.43,1.1,5.19,5.19,0,0,1,0,5.9,3,3,0,0,1-2.43,1.1A2.79,2.79,0,0,1,374.77,219.07Zm3.54-1.06a4.65,4.65,0,0,0,0-5,2.37,2.37,0,0,0-1.95-.94,2.33,2.33,0,0,0-1.9,1,3.8,3.8,0,0,0-.8,2.43,3.75,3.75,0,0,0,.8,2.42,2.32,2.32,0,0,0,1.9,1A2.37,2.37,0,0,0,378.31,218Z"/><path class="cls-3" d="M381.23,221.64h.56c.28,0,.58-.22.9-.67a7.1,7.1,0,0,0,.89-1.75l-2.95-7.64h.67l2.62,6.75,2.61-6.75h.71l-3.32,8.54a4.33,4.33,0,0,1-.88,1.51,1.65,1.65,0,0,1-1.12.56h-.69Z"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

+274
View File
@@ -0,0 +1,274 @@
<template>
<div class="content">
<b-container fluid class="h-100">
<b-row align-h="around">
<b-col lg="7">
<div class="card">
<h2>Add Cluster</h2>
<div class="add-cluster">
<b-form @submit.prevent="doAddCluster">
<b-form-group
label="Choose config:"
>
<b-form-select
v-model="kubecontext"
:options="contextNames"
@change="onSelect($event)"
/>
<b-button v-b-toggle.collapse-advanced variant="link">
Proxy settings
</b-button>
</b-form-group>
<b-collapse id="collapse-advanced">
<b-form-group
label="HTTP Proxy server. Used for communicating with Kubernetes API."
description="A HTTP proxy server URL (format: http://<address>:<port>)."
>
<b-form-input
v-model="httpsProxy"
/>
</b-form-group>
</b-collapse>
<b-form-group
label="Kubeconfig:"
v-if="status === 'ERROR' || kubecontext === 'custom'"
>
<div class="editor">
<prism-editor v-model="clusterconfig" language="yaml" />
</div>
</b-form-group>
<b-alert variant="danger" show v-if="status === 'ERROR'">
{{ errorMsg }}
<div v-if="errorDetails !== ''">
<b-button v-b-toggle.collapse-error variant="link" size="sm">
Show details
</b-button>
<b-collapse id="collapse-error">
<code>
{{ errorDetails }}
</code>
</b-collapse>
</div>
</b-alert>
<b-form-row>
<b-col>
<b-button variant="primary" type="submit" :disabled="clusterconfig === ''">
<b-spinner small v-if="isProcessing" label="Small Spinner" />
{{ addButtonText }}
</b-button>
</b-col>
</b-form-row>
</b-form>
</div>
</div>
</b-col>
<b-col lg="5" class="help d-none d-lg-block">
<h3>Clusters associated with Lens</h3>
<p>
Add clusters by clicking the <span class="text-primary">Add Cluster</span> button.
You'll need to obtain a working kubeconfig for the cluster you want to add.
</p>
<p>
Each <a href="https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/#context">cluster context</a> is added as a separate item in the left-side cluster menu to allow you to operate easily on multiple clusters and/or contexts.
</p>
<p>
For more information on kubeconfig see <a href="https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/">Kubernetes docs</a>
</p>
<p>
NOTE: Any manually added cluster is not merged into your kubeconfig file.
</p>
<p>
To see your currently enabled config with <code>kubectl</code>, use <code>kubectl config view --minify --raw</code> command in your terminal.
</p>
<p>
When connecting to a cluster, make sure you have a valid and working kubeconfig for the cluster. Following lists known "gotchas" in some authentication types used in kubeconfig with Lens app.
</p>
<a href="https://kubernetes.io/docs/reference/access-authn-authz/authentication/#option-1-oidc-authenticator">
<h4>OIDC (OpenID Connect)</h4>
</a>
<div>
<p>
When connecting Lens to OIDC enabled cluster, there's few things you as a user need to take into account.
</p>
<b>Dedicated refresh token</b>
<p>
As Lens app utilized kubeconfig is "disconnected" from your main kubeconfig Lens needs to have it's own refresh token it utilizes.
If you share the refresh token with e.g. <code>kubectl</code> who ever uses the token first will invalidate it for the next user.
One way to achieve this is with <a href="https://github.com/int128/kubelogin">kubelogin</a> tool by removing the tokens (both <code>id_token</code> and <code>refresh_token</code>) from the config and issuing <code>kubelogin</code> command. That'll take you through the login process and will result you having "dedicated" refresh token.
</p>
</div>
<h4>Exec auth plugins</h4>
<p>
When using <a href="https://kubernetes.io/docs/reference/access-authn-authz/authentication/#configuration">exec auth</a> plugins make sure the paths that are used to call any binaries are full paths as Lens app might not be able to call binaries with relative paths. Make also sure that you pass all needed information either as arguments or env variables in the config, Lens app might not have all login shell env variables set automatically.
</p>
</b-col>
</b-row>
</b-container>
</div>
</template>
<script>
import * as PrismEditor from 'vue-prism-editor'
import * as k8s from "@kubernetes/client-node"
import { dumpConfigYaml } from "../../main/k8s"
import ClustersMixin from "@/mixins/ClustersMixin";
class ClusterAccessError extends Error {}
export default {
name: 'AddClusterPage',
mixins: [ClustersMixin],
props: { },
components: {
PrismEditor,
},
data(){
return {
clusterconfig: "",
httpsProxy: "",
kubecontext: "",
status: "",
errorMsg: "",
errorCluster: "",
errorDetails: "",
seenContexts: []
}
},
mounted: function() {
this.$store.dispatch("reloadAvailableKubeContexts");
this.seenContexts = JSON.parse(JSON.stringify(this.$store.getters.seenContexts)) // clone seenContexts from store
this.storeSeenContexts()
},
computed: {
isProcessing: function() {
return this.status === "PROCESSING";
},
addButtonText: function() {
if (this.kubecontext === "custom") {
return "Add Cluster(s)"
} else {
return "Add Cluster"
}
},
contextNames: function() {
const configs = this.availableContexts
const names = configs.map((kc) => {
return { text: kc.currentContext + (this.isNewContext(kc.currentContext) ? " (new)": ""), value: dumpConfigYaml(kc) }
})
names.unshift({text: "Select kubeconfig", value: ""})
names.push({text: "Custom ...", value: "custom"})
return names;
},
},
methods: {
isNewContext(context) {
return this.newContexts.indexOf(context) > -1
},
storeSeenContexts() {
const configs = this.$store.getters.availableKubeContexts
const contexts = configs.map((kc) => {
return kc.currentContext
})
this.$store.dispatch("addSeenContexts", contexts)
},
onSelect: function() {
this.status = "";
if (this.kubecontext === "custom") {
this.clusterconfig = "";
} else {
this.clusterconfig = this.kubecontext;
}
},
isOidcAuth: function(authProvider) {
if (!authProvider) { return false }
if (authProvider.name === "oidc") { return true }
return false;
},
doAddCluster: async function() {
// Clear previous error details
this.errorMsg = ""
this.errorCluster = ""
this.errorDetails = ""
this.status = "PROCESSING"
try {
const kc = new k8s.KubeConfig();
kc.loadFromString(this.clusterconfig); // throws TypeError if we cannot parse kubeconfig
const clusterInfo = {
kubeConfig: dumpConfigYaml(kc),
preferences: {
clusterName: kc.currentContext
},
workspace: this.$store.getters.currentWorkspace.id
}
if (this.httpsProxy) {
clusterInfo.preferences.httpsProxy = this.httpsProxy
}
let res = await this.$store.dispatch('addCluster', clusterInfo)
console.log("addCluster result:", res)
if(!res){
this.status = "ERROR";
return false;
}
this.status = "SUCCESS"
this.$router.push({
name: "cluster-page",
params: {
id: res.id
},
}).catch((err) => {})
} catch (error) {
console.log("addCluster raised:", error)
if(typeof error === 'string') {
this.errorMsg = error;
} else if(error instanceof TypeError) {
this.errorMsg = "cannot parse kubeconfig";
} else if(error.response && error.response.statusCode === 401) {
this.errorMsg = "invalid kubeconfig (access denied)"
} else if(error.message) {
this.errorMsg = error.message
} else if(error instanceof ClusterAccessError) {
this.errorMsg = `Invalid kubeconfig context ${error.context}`
this.errorCluster = error.cluster
this.errorDetails = error.details
}
this.status = "ERROR";
return false;
}
return true;
}
}
}
</script>
<style scoped lang="scss">
.help{
border-left: 1px solid #353a3e;
padding-top: 20px;
&:first-child{
padding-top: 0;
}
h3{
padding: 0.75rem 0 0.75rem 0;
}
height: 100vh;
overflow-y: auto;
}
h2{
padding: 0.75rem;
}
.card {
margin-top: 20px;
}
.add-cluster {
padding: 0.75rem;
}
.btn-link {
padding-left: 0;
}
</style>
@@ -0,0 +1,115 @@
<template>
<div class="content">
<ClosePageButton />
<div class="container-fluid lens-workspaces">
<div class="header sticky-top">
<h2><i class="material-icons">layers</i> Workspaces</h2>
</div>
<div class="row">
<div class="col-3" />
<div class="col-6">
<h2>New Workspace</h2>
<b-form @submit.prevent="createWorkspace">
<b-form-group
label="Name:"
label-for="input-name"
>
<b-form-input
id="input-name"
v-model="workspace.name"
trim
/>
</b-form-group>
<b-form-group
label="Description:"
label-for="input-description"
>
<b-form-input
id="input-description"
v-model="workspace.description"
trim
/>
</b-form-group>
<b-form-row>
<b-col>
<b-button variant="primary" type="submit">
Create Workspace
</b-button>
</b-col>
</b-form-row>
</b-form>
</div>
<div class="col-3" />
</div>
</div>
</div>
</template>
<script>
import { v4 as uuid } from "uuid";
import ClosePageButton from "@/components/common/ClosePageButton";
export default {
name: 'AddWorkspacePage',
components: {
ClosePageButton
},
data() {
return {
workspace: {
id: uuid(),
name: "",
description: ""
},
errors: {
name: null,
description: null
}
}
},
computed: {
},
methods: {
createWorkspace: function() {
this.$store.commit("addWorkspace", this.workspace)
this.$router.push({
name: "workspaces-page"
})
}
},
mounted: function() {
this.$store.commit("hideMenu");
},
destroyed: function() {
this.$store.commit("showMenu");
}
}
</script>
<style lang="scss" scoped>
#app > .main-view > .content {
left: 70px;
right: 70px;
}
h2 {
i {
position: relative;
top: 6px;
}
}
.lens-workspaces {
height: 100%;
overflow-y: scroll;
& input {
background-color: #252729 !important;
border: 0px !important;
color: #87909c !important;
}
.header {
padding-top: 15px;
}
}
</style>
@@ -0,0 +1,88 @@
<template>
<div class="bottom-bar">
<div id="workspace-area">
<i class="material-icons">layers</i> {{ currentWorkspace.name }}
</div>
<b-popover target="workspace-area" triggers="click" placement="top" :show.sync="show">
<template v-slot:title>
<a href="#" @click.prevent="goWorkspaces"><i class="material-icons">layers</i> Workspaces</a>
</template>
<ul
v-for="workspace in workspaces"
:key="workspace.id"
:workspace="workspace"
class="list-group list-group-flush"
>
<li class="list-group-item">
<a href="#" @click.prevent="switchWorkspace(workspace)">{{ workspace.name }}</a>
</li>
</ul>
</b-popover>
</div>
</template>
<script>
export default {
name: "BottomBar",
data() {
return {
show: false
}
},
computed: {
currentWorkspace: function() {
return this.$store.getters.currentWorkspace;
},
workspaces: function() {
return this.$store.getters.workspaces;
}
},
methods: {
switchWorkspace: function(workspace) {
this.show = false;
this.$store.commit("setCurrentWorkspace", workspace);
this.$store.dispatch("clearClusters");
this.$store.dispatch("refreshClusters", workspace);
this.$router.push({
name: "landing-page"
})
},
goWorkspaces: function() {
this.$router.push({
name: "workspaces-page"
})
}
}
}
</script>
<style scoped lang="scss">
.bottom-bar {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 20px;
background-color: var(--kontena-blue);
z-index: 2000;
}
#workspace-area {
position: absolute;
bottom: 2px;
right: 10px;
display: block;
color: #fff;
opacity: 0.9;
font-size: 11px;
cursor: pointer;
&.active{
opacity: 1.0;
}
i {
position: relative;
top: 4px;
font-size: 14px;
}
}
</style>
+186
View File
@@ -0,0 +1,186 @@
<template>
<div class="content">
<div class="h-100">
<div class="wrapper" v-if="status === 'LOADING'">
<cube-spinner text="" />
<div class="auth-output">
<!-- eslint-disable-next-line vue/no-v-html -->
<pre class="auth-output" v-html="authOutput" />
</div>
</div>
<div class="wrapper" v-if="status === 'ERROR'">
<div class="error">
<i class="material-icons">{{ error_icon }}</i>
<div class="text-center">
<h2>{{ cluster.preferences.clusterName }}</h2>
<!-- eslint-disable-next-line vue/no-v-html -->
<pre v-html="authOutput" />
<b-button variant="link" @click="tryAgain">
Reconnect
</b-button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import CubeSpinner from "@/components/CubeSpinner";
import { webviewTag } from 'electron';
export default {
name: "ClusterPage",
components: {
CubeSpinner
},
data(){
return {
authOutput: ""
}
},
computed: {
cluster: function() {
return this.$store.getters.clusterById(this.$route.params.id);
},
online: function() {
if (!this.cluster) { return false }
return this.cluster.online;
},
accessible: function() {
if (!this.cluster) { return false }
return this.cluster.accessible;
},
lens: function() {
return this.$store.getters.lensById(this.cluster.id);
},
status: function() {
if (this.cluster) {
if (this.cluster.accessible && this.lens.loaded === true) {
return "SUCCESS";
} else if (this.cluster.accessible === false) {
return "ERROR";
}
return "LOADING";
}
return "ERROR";
},
error_icon: function() {
if (!this.cluster.online) {
return "cloud_off"
} else {
return "https"
}
}
},
methods: {
tryAgain: function() {
this.authOutput = ""
this.cluster.accessible = null
setTimeout(() => {
this.loadLens()
}, 1000)
},
loadLens: function() {
this.authOutput = "Connecting ...\n";
this.$promiseIpc.on(`kube-auth:${this.cluster.id}`, (output) => {
this.authOutput += output.data;
})
this.toggleLens();
return this.$store.dispatch("refineCluster", this.$route.params.id);
},
lensLoaded: function() {
console.log("lens loaded")
this.lens.loaded = true;
this.$store.commit("updateLens", this.lens);
this.lens.webview.getWebContents().executeJavaScript(
// disable dragging of ui links
'document.styleSheets[0].insertRule("*, *::after, *::before { -webkit-user-drag: none; -webkit-app-region: no-drag; }", 1);'
);
},
addWebview: function() {
const container = document.getElementById("lens-container");
if (!container || !this.lens.webview) {
return
}
container.style = "display: block;"
let webview = null
container.childNodes.forEach((child) => {
if (child === this.lens.webview) {
webview = child
}
})
if (!webview) {
container.appendChild(this.lens.webview)
}
container.childNodes.forEach((child) => {
if (child !== this.lens.webview) {
child.style = "display: none;"
} else {
child.style = "top: 0; bottom: 20px;"
}
})
},
// Called only when online state changes
toggleLens: function() {
if (!this.lens) { return }
if (this.accessible) {
this.activateLens();
} else {
this.hideLens();
}
},
activateLens: async function() {
console.log("activate lens")
if (!this.lens.webview) {
console.log("create webview")
const webview = document.createElement('webview');
webview.addEventListener('did-finish-load', this.lensLoaded);
webview.src = this.cluster.url;
this.lens.webview = webview;
this.$store.dispatch("attachWebview", this.lens);
}
this.$store.dispatch("attachWebview", this.lens);
this.$tracker.event("cluster", "open");
},
hideLens: function() {
this.$store.dispatch("hideWebviews");
}
},
created() {
this.loadLens();
},
destroyed() {
this.hideLens();
},
watch: {
"$route": "loadLens",
"online": "toggleLens",
"cluster": "toggleLens",
"accessible": function(newStatus, oldStatus) {
console.log("accessible watch, vals:", newStatus, oldStatus);
if(newStatus === false) { // accessble == false
this.$tracker.event("cluster", "open-failed");
}
},
}
};
</script>
<style scoped lang="scss">
div.auth-output {
padding-top: 250px;
width: 70%;
pre {
height: 100px;
text-align: center;
}
}
.error {
width: 90%;
}
pre {
font-size: 80%;
overflow: auto;
max-height: 150px;
}
</style>
@@ -0,0 +1,134 @@
<template>
<div class="settings-section">
<b>Metrics</b>
<p class="description">
Enable timeseries data visualization (Prometheus stack) for your cluster. Install this only if you don't have existing Prometheus stack installed. You can see preview of manifests <a href="https://github.com/kontena/lens/tree/master/app/manifests/metrics">here</a>.
</p>
<div class="actions">
<b-button @click="install" v-if="!settings.installed" :disabled="!cluster.isAdmin || isProcessing || !canInstall" variant="primary">
<b-spinner small v-if="isProcessing" label="Small Spinner" />
Install
</b-button>
<b-button @click="uninstall" v-if="settings.installed" :disabled="!cluster.isAdmin || isProcessing" variant="danger">
<b-spinner small v-if="isProcessing" label="Small Spinner" />
Uninstall
</b-button>
<b-button @click="upgrade" v-if="isUpgradeAvailable" :disabled="!cluster.isAdmin" variant="primary">
Upgrade
</b-button>
<b-alert show variant="danger" v-if="status === 'ERROR'">
{{ errorMsg }}
</b-alert>
</div>
</div>
</template>
<script>
const semver = require('semver');
export default {
name: 'Metrics',
components: {},
props: {
cluster: {
type: Object,
required: true,
default: null
},
feature: {
type: String,
required: true,
default: null
},
settings: {
type: Object,
required: false,
default: Object
}
},
data(){
return {
status: "",
errorMsg: "",
}
},
computed:{
isUpgradeAvailable: function() {
if(!this.settings.installed) return false;
if(!this.settings.currentVersion) return false;
if(!this.settings.latestVersion) return false;
let currentVersion = (this.settings.currentVersion.charAt(0) === "v") ? this.settings.currentVersion.substr(1) : this.settings.currentVersion;
let latestVersion = (this.settings.latestVersion.charAt(0) === "v") ? this.settings.latestVersion.substr(1) : this.settings.latestVersion;
return semver.gt(latestVersion, currentVersion)
},
isProcessing: function() {
return this.status === "PROCESSING";
},
canInstall: function() {
return !this.cluster.preferences.prometheus
}
},
methods: {
install: async function(){
this.status="PROCESSING";
let error = null;
let result = await this.$store.dispatch("installClusterFeature", {
name: this.feature,
clusterId: this.cluster.id,
config: null,
}).catch(e => {
error = e;
return false; // just returning false to promise
})
// handle exceptions here; but oh why, why do we have to???
if(error){
this.status = "ERROR";
this.errorMsg = error;
return false;
}
// now let's look at result...
if(result.success === false) {
this.status = "ERROR"
this.errorMsg = result.message
} else {
this.$store.dispatch("refineCluster", this.cluster.id);
this.status = "";
this.errorMsg = "";
}
},
uninstall: async function(){
this.status="PROCESSING";
let error = null;
try {
let result = await this.$store.dispatch("uninstallClusterFeature", {
name: this.feature,
clusterId: this.cluster.id,
})
console.log("uninstall result:", result);
this.$store.dispatch("refineCluster", this.cluster.id);
this.status="SUCCESS";
this.errorMsg = "";
} catch(error) {
this.status = "ERROR"
this.errorMsg = error
console.log("Error uninstalling:", error);
}
return true;
},
upgrade: async function(){
// todo
return true;
},
},
mounted: function(){
console.log(this.settings);
}
}
</script>
<style scoped lang="scss">
</style>
@@ -0,0 +1,131 @@
<template>
<div class="settings-section">
<b>User Mode</b>
<p class="description">
User Mode feature enables non-admin users to see namespaces they have access to. This is achieved by configuring RBAC rules so that every authenticated user is granted to list namespaces.
</p>
<div class="actions">
<b-button @click="install" v-if="!settings.installed" :disabled="!cluster.isAdmin || isProcessing" variant="primary">
<b-spinner small v-if="isProcessing" label="Small Spinner" />
Install
</b-button>
<b-button @click="uninstall" v-if="settings.installed" :disabled="!cluster.isAdmin || isProcessing" variant="danger">
<b-spinner small v-if="isProcessing" label="Small Spinner" />
Uninstall
</b-button>
<b-button @click="upgrade" v-if="isUpgradeAvailable" :disabled="!cluster.isAdmin" variant="primary">
Upgrade
</b-button>
<b-alert show variant="danger" v-if="status === 'ERROR'">
{{ errorMsg }}
</b-alert>
</div>
</div>
</template>
<script>
const semver = require('semver');
export default {
name: 'UserMode',
components: {},
props: {
cluster: {
type: Object,
required: true,
default: null
},
feature: {
type: String,
required: true,
default: null
},
settings: {
type: Object,
required: false,
default: Object
}
},
data(){
return {
status: "",
errorMsg: "",
}
},
computed:{
isProcessing: function() {
return this.status === "PROCESSING";
},
isUpgradeAvailable: function() {
if(!this.settings.installed) return false;
if(!this.settings.currentVersion) return false;
if(!this.settings.latestVersion) return false;
let currentVersion = (this.settings.currentVersion.charAt(0) === "v") ? this.settings.currentVersion.substr(1) : this.settings.currentVersion;
let latestVersion = (this.settings.latestVersion.charAt(0) === "v") ? this.settings.latestVersion.substr(1) : this.settings.latestVersion;
return semver.gt(latestVersion, currentVersion)
},
},
methods: {
install: async function(){
this.status="PROCESSING";
let error = null;
let result = await this.$store.dispatch("installClusterFeature", {
name: this.feature,
clusterId: this.cluster.id,
config: null,
}).catch(e => {
error = e;
return false; // just returning false to promise
})
// handle exceptions here; but oh why, why do we have to???
if(error){
this.status = "ERROR";
this.errorMsg = error;
return false;
}
// now let's look at result...
if(result.success === false) {
this.status = "ERROR"
this.errorMsg = result.message
} else {
this.$store.dispatch("refineCluster", this.cluster.id);
this.status = "";
this.errorMsg = "";
}
},
uninstall: async function(){
this.status="PROCESSING";
let error = null;
try {
let result = await this.$store.dispatch("uninstallClusterFeature", {
name: this.feature,
clusterId: this.cluster.id,
})
console.log("uninstall result:", result);
this.$store.dispatch("refineCluster", this.cluster.id);
this.status="SUCCESS";
this.errorMsg = "";
} catch(error) {
this.status = "ERROR"
this.errorMsg = error
console.log("Error uninstalling:", error);
}
return true;
},
upgrade: async function(){
// todo
return true;
},
},
mounted: function(){
console.log(this.settings);
}
}
</script>
<style scoped lang="scss">
</style>
@@ -0,0 +1,7 @@
import Metrics from './Metrics'
import UserMode from './UserMode'
export default {
Metrics,
UserMode
}
@@ -0,0 +1,51 @@
<template>
<div id="cluster-features" class="row">
<div class="col-12">
<h2>Features</h2>
<component
v-for="featureName in features"
:key="featureName"
:is="featureName"
:cluster="cluster"
:feature="featureName"
:settings="feature(featureName)"
/>
</div>
</div>
</template>
<script>
// register all sub components
import components from './Components'
export default {
name: 'ClusterFeatures',
components: components,
props: {
cluster: {
type: Object,
default: null,
}
},
data(){
return {
}
},
computed:{
features: function() {
if (!this.cluster) return []
if (!this.cluster.features) return []
return Object.keys(this.cluster.features)
}
},
methods: {
feature: function( name ){ return this.cluster.features[name] }
}
}
</script>
<style scoped lang="scss">
h2{
margin-top: 20px;
}
</style>
@@ -0,0 +1,148 @@
<template>
<div class="settings-section">
<b>Cluster Icon</b>
<p>
Define cluster icon. By default automatically generated.
</p>
<div class="row">
<span class="cluster-settings-icon">
<img v-if="preferences.icon" :src="preferences.icon" class="cluster-icon">
<hashicon v-else :name="cluster.preferences.clusterName" size="38" />
</span>
<div class="col">
<b-form-file
ref="fileUpload"
v-model="file"
accept="image/jpeg, image/png, image/gif"
:state="Boolean(file)"
placeholder="Browse for new icon..."
drop-placeholder="Drop file here..."
browse-text=""
@input="upload"
/>
</div>
</div>
<div class="mt-3">
<a href="#" @click.prevent="reset" v-if="preferences.icon" class="text-muted">
Use automatically generated icon
</a>
<b-alert show variant="danger" v-if="status === 'ERROR'">
{{ errorMsg }}
</b-alert>
</div>
</div>
</template>
<script>
import hashicon from "../../hashicon/hashicon";
export default {
name: 'ClusterIcon',
components: {
hashicon
},
props: {
cluster: {
type: Object,
required: true,
default: null
}
},
data(){
return {
status: "",
errorMsg: "",
file: null,
}
},
computed:{
preferences: function() {
return this.cluster.preferences;
},
isProcessing: function() {
return this.status === "PROCESSING";
},
isResetProcessing: function() {
return this.resetStatus === "PROCESSING";
},
},
methods: {
reset: async function(){
this.resetStatus = "PROCESSING";
let error = null;
let result = await this.$store.dispatch("resetClusterIcon", {
clusterId: this.cluster.id
}).catch(e => {
error = e;
return false; // just returning false to promise
})
// handle exceptions here; but oh why, why do we have to???
if(error){
this.resetStatus = "ERROR";
this.errorMsg = error;
return false;
}
// now let's look at result...
if(result.success === false) {
this.resetStatus = "ERROR"
this.errorMsg = result.message
} else {
this.resetStatus = "";
this.errorMsg = "";
}
},
upload: async function(file){
if(!file) {
return
}
this.status="PROCESSING";
let error = null;
let result = await this.$store.dispatch("uploadClusterIcon", {
clusterId: this.cluster.id,
name: file.name,
path: file.path
}).catch(e => {
error = e;
return false; // just returning false to promise
})
// handle exceptions here; but oh why, why do we have to???
if(error){
this.status = "ERROR";
this.errorMsg = error;
return false;
}
// now let's look at result...
if(result.success === false) {
this.status = "ERROR"
this.errorMsg = result.message
} else {
this.status = "";
this.errorMsg = "";
this.file = null;
}
},
},
mounted: function(){
}
}
</script>
<style scoped lang="scss">
.description{
padding: 0.75rem;
}
.actions{
border-top: 1px solid rgba(255,255,255,0.10);
padding: 15px;
}
.cluster-settings-icon {
background: #252729;
margin-left: 15px;
padding: 5px;
}
</style>
@@ -0,0 +1,49 @@
<template>
<div class="settings-section">
<b>Cluster Name</b>
<p>
Define cluster name.
</p>
<b-form-input v-model="cluster.preferences.clusterName"
:placeholder="cluster.preferences.clusterName"
:state="errors.name"
trim
@blur="onSave"
/>
</div>
</template>
<script>
export default {
name: 'ClusterName',
props: {
cluster: {
type: Object,
required: true,
default: null
}
},
data(){
return {
errors: {
name: null
}
}
},
methods: {
onSave: function() {
if(this.cluster.preferences.clusterName === "") {
this.errors.name = false
return
} else {
this.errors.name = true
}
this.$store.dispatch("storeCluster", this.cluster);
}
}
}
</script>
<style>
</style>
@@ -0,0 +1,52 @@
<template>
<div class="settings-section">
<b>Cluster Workspace</b>
<p>
Define cluster <a href="/#/workspaces">workspace</a>.
</p>
<b-form-select
v-model="cluster.workspace"
:options="workspaces"
@change="onSave"
/>
</div>
</template>
<script>
export default {
name: 'ClusterWorkspace',
props: {
cluster: {
type: Object,
required: true,
default: null
}
},
computed: {
workspaces: function() {
return this.$store.getters.workspaces.map((ws) => {
return {
value: ws.id,
text: ws.name
}
});
}
},
methods: {
onSave: async function(workspaceId) {
this.cluster.workspace = workspaceId
await this.$store.dispatch("storeCluster", this.cluster);
const ws = this.$store.getters.workspaceById(this.cluster.workspace);
if (ws) {
this.$store.commit("setCurrentWorkspace", ws);
await this.$store.dispatch("clearClusters");
await this.$store.dispatch("refreshClusters", ws);
}
}
}
}
</script>
<style>
</style>
@@ -0,0 +1,35 @@
<template>
<div id="cluster-general" class="row">
<div class="col-12">
<h2>General</h2>
<cluster-name :cluster="cluster" />
<cluster-workspace :cluster="cluster" />
<cluster-icon :cluster="cluster" />
</div>
</div>
</template>
<script>
import ClusterIcon from "./ClusterIcon";
import ClusterName from "./ClusterName"
import ClusterWorkspace from "./ClusterWorkspace"
export default {
name: "ClusterSettingsGeneral",
props: {
cluster: {
type: Object,
required: true,
default: null
}
},
components: {
ClusterIcon,
ClusterName,
ClusterWorkspace
}
}
</script>
<style>
</style>
@@ -0,0 +1,69 @@
<template>
<div id="cluster-status" class="cluster-settings-overview row">
<div class="col-12">
<h2>Status</h2>
<div class="settings-section">
<b>Cluster Status</b>
<p>
Cluster status information including detected distribution, kernel version and online status
</p>
<table class="table">
<tbody>
<tr>
<th scope="row">
Online Status
</th>
<td>{{ cluster.online ? "online" : "offline (" + (cluster.failureReason || "unknown reason") + ")" }}</td>
</tr>
<tr>
<th scope="row">
Distribution
</th>
<td>{{ cluster.distribution }}</td>
</tr>
<tr>
<th scope="row">
Kernel Version
</th>
<td>{{ cluster.version }}</td>
</tr>
<tr>
<th scope="row">
API Address
</th>
<td>{{ cluster.apiUrl }}</td>
</tr>
<tr v-if="cluster.nodes">
<th scope="row">
Nodes Count
</th>
<td>{{ cluster.nodes }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'ClusterSettingsOverview',
props: {
cluster: {
type: Object,
default: null,
}
},
data(){
return {
}
},
methods: {
}
}
</script>
<style lang="scss">
</style>
@@ -0,0 +1,134 @@
<template>
<div class="row">
<div class="col-12">
<div class="cluster-settings-section">
<b>HTTP Proxy</b>
<b-form-group
label="HTTP Proxy server. Used for communicating with Kubernetes API."
description="A HTTP proxy server URL (format: http://<address>:<port>)."
>
<b-form-input
v-model="cluster.preferences.httpsProxy"
id="input-httpsproxy"
@blur="onHttpsProxySave"
/>
</b-form-group>
</div>
</div>
<div class="col-12">
<div class="cluster-settings-section">
<b>Prometheus</b>
<p>Use pre-installed Prometheus service for metrics. Please refer to the <a href="https://github.com/kontena/lens/blob/master/troubleshooting/custom-prometheus.md">guide</a> for possible configuration changes.</p>
<b-form-group
label="Prometheus service address."
description="A path to an existing Prometheus installation (<namespace>/<service>:<port>)."
>
<b-form-input
v-model="prometheusPath"
placeholder="lens-metrics/prometheus:80"
id="input-prometheuspath"
@blur="onPrometheusSave"
/>
</b-form-group>
</div>
</div>
<div class="col-12">
<div class="cluster-settings-section">
<b>Working Directory</b>
<b-form-group
label="Terminal working directory."
description="An explicit start path where the terminal will be launched, this is used as the current working directory (cwd) for the shell process."
>
<b-form-input
v-model="cluster.preferences.terminalCWD"
placeholder="$HOME"
id="input-terminalcwd"
@blur="onTerminalCwdSave"
:state="errors.terminalcwd"
:formatter="expandPath"
/>
</b-form-group>
</div>
</div>
</div>
</template>
<script>
import { lstatSync } from "fs"
export default {
name: 'ClusterSettingsPreferences',
props: {
cluster: {
type: Object,
default: null,
}
},
data(){
return {
errors: {
terminalcwd: null
},
prometheusPath: ""
}
},
mounted: function() {
if (this.cluster.preferences.prometheus) {
const prom = this.cluster.preferences.prometheus;
this.prometheusPath = `${prom.namespace}/${prom.service}:${prom.port}`
}
},
computed: {
},
methods: {
parsePrometheusPath: function(path) {
let parsed = path.split(/\/|:/)
return {
namespace: parsed[0],
service: parsed[1],
port: parsed[2]
}
},
expandPath: function(value, event) {
if(value === "") {
this.errors.terminalcwd = null
return value;
}
if(value.substr(0, 1) == "~") {
value = process.env.HOME + value.substr(1);
} else if(value.substr(0, 5) == "$HOME") {
value = process.env.HOME + value.substr(5);
}
try {
this.errors.terminalcwd = lstatSync(value).isDirectory()
} catch(_err) {
this.errors.terminalcwd = false
}
return value;
},
onHttpsProxySave: function() {
if(this.cluster.preferences.httpsProxy === "") this.cluster.preferences.httpsProxy = null
this.$store.dispatch("storeCluster", this.cluster);
},
onPrometheusSave: function() {
if (this.prometheusPath === "") {
this.cluster.preferences.prometheus = null;
} else {
this.cluster.preferences.prometheus = this.parsePrometheusPath(this.prometheusPath);
}
this.$store.dispatch("storeCluster", this.cluster);
},
onTerminalCwdSave: function() {
if(this.cluster.preferences.terminalCWD === "") this.cluster.preferences.terminalCWD = null
this.$store.dispatch("storeCluster", this.cluster);
}
}
}
</script>
<style lang="scss">
</style>
@@ -0,0 +1,185 @@
<template>
<div class="content">
<ClosePageButton />
<div class="container-fluid settings">
<div v-if="cluster" class="header sticky-top">
<h2>
<div class="icon d-inline">
<img v-if="preferences.icon" :src="preferences.icon" class="cluster-icon">
<hashicon v-else :name="cluster.preferences.clusterName" size="30" />
</div>
{{ cluster.preferences.clusterName }}
</h2>
</div>
<div v-if="cluster" class="row">
<div class="col-3" />
<div ref="content" id="cluster-settings" class="col-6">
<cluster-settings-overview :cluster="cluster" />
<cluster-settings-general :cluster="cluster" />
<cluster-settings-preferences :cluster="cluster" />
<cluster-settings-features :cluster="cluster" />
<div id="cluster-remove" class="settings-section">
<h2>Remove Cluster</h2>
<b-button v-b-modal.bv-modal-confirm variant="danger" type="submit">
Remove Cluster
</b-button>
<!-- Modal confirmation -->
<b-modal id="bv-modal-confirm" @ok="removeCluster" ok-title="Remove" ok-variant="danger" title-class="confirm-header" hide-backdrop title="Confirm cluster delete">
<p>Are you sure you want to delete <strong>{{ preferences.clusterName }}</strong> cluster from Lens?</p>
</b-modal>
</div>
</div>
</div>
<div v-if="!cluster">
<cube-spinner text="Loading..." />
</div>
</div>
</div>
</template>
<script>
import CubeSpinner from "@/components/CubeSpinner";
import ClusterSettingsGeneral from "./General";
import ClusterSettingsOverview from "./Overview";
import ClusterSettingsPreferences from "./Preferences";
import ClusterSettingsFeatures from "./Features";
import hashicon from "../hashicon/hashicon";
import ClosePageButton from "@/components/common/ClosePageButton";
export default {
name: 'ClusterSettingsPage',
components: {
CubeSpinner,
ClosePageButton,
ClusterSettingsGeneral,
ClusterSettingsOverview,
ClusterSettingsPreferences,
ClusterSettingsFeatures,
hashicon
},
computed: {
cluster: function() {
return this.$store.getters.clusterById(this.$route.params.id);
},
preferences: function() {
return this.cluster.preferences;
}
},
methods: {
loadLens: function() {
this.$store.dispatch("refineCluster", this.cluster.id);
},
removeCluster: async function(){
let res = await this.$store.dispatch('removeCluster', this.cluster.id);
if(!res) return false;
this.$router.push({
name: "landing-page"
}).catch(err => {})
return true;
},
},
created() {
this.loadLens()
},
watch: {
"$route": "loadLens"
}
}
</script>
<style lang="scss">
.header {
& h2 {
padding: 0.5rem
}
padding-top: 5px;
padding-left: 15px;
border-bottom: 1px solid rgb(53, 58, 62);
background-color: #1e2124;
& img {
margin-right: 10px;
height: 35px;
}
.hashicon {
position: relative;
height: 40px;
margin-right: 10px;
& canvas{
position: relative;
bottom: -6px;
}
}
}
.settings {
height: 100%;
overflow-y: scroll;
}
.settings-section {
margin-bottom: 20px;
}
#cluster-settings {
& input {
background-color: #252729 !important;
border: 0px !important;
color: #87909c !important;
}
}
.cluster-icon {
width: 38px;
}
h2{
padding: 0.75rem;
padding-left: 0px;
}
.table th, .table td{
padding-bottom: 0px !important;
}
.custom-select {
-webkit-appearance: inherit;
}
.collapsed > .when-opened, :not(.collapsed) > .when-closed {
display: none;
}
.confirm-header{
color: #dc3545;
}
.navbar {
align-items: flex-start !important;
}
.nav-link {
padding: 0px !important;
&.active {
color: white;
}
}
.custom-file-label{
background: transparent !important;
color: #3d90ce !important;
}
.was-validated .custom-file-input:invalid ~ .custom-file-label, .custom-file-input.is-invalid ~ .custom-file-label {
border-color: transparent !important;
}
.custom-file-label::after {
content: "" !important;
width: 0px;
padding: 0px !important;
background-color: transparent !important;
cursor: pointer;
}
</style>
+116
View File
@@ -0,0 +1,116 @@
<template>
<div class="loading">
<div class="CubeSpinner">
<p>{{ text }}</p>
<div class="sk-cube-grid">
<div class="sk-cube sk-cube1" />
<div class="sk-cube sk-cube2" />
<div class="sk-cube sk-cube3" />
<div class="sk-cube sk-cube4" />
<div class="sk-cube sk-cube5" />
<div class="sk-cube sk-cube6" />
<div class="sk-cube sk-cube7" />
<div class="sk-cube sk-cube8" />
<div class="sk-cube sk-cube9" />
</div>
</div>
</div>
</template>
<script>
export default {
name: 'CubeSpinner',
components: {},
methods: {},
props: {
text: {
type: String,
required: false,
default: "Connecting ..."
}
},
}
</script>
<style scoped lang="scss">
.loading {
position: absolute;
left: 50%;
top: 50%;
margin: -44px;
margin-top: -70px;
z-index: 1000;
}
/* http://tobiasahlin.com/spinkit/ */
.CubeSpinner {
--size: 70px;
.sk-cube-grid {
width: 70px;
height: 70px;
transform: rotate(45deg);
margin: 24px auto;
border-bottom-left-radius: 3px;
border-top-right-radius: 3px;
overflow: hidden;
}
.sk-cube-grid .sk-cube {
width: 33%;
height: 33%;
float: left;
-webkit-animation: sk-cubeGridScaleDelay 1.3s infinite ease-in-out;
animation: sk-cubeGridScaleDelay 1.3s infinite ease-in-out;
}
.sk-cube-grid .sk-cube1 {
-webkit-animation-delay: 0.2s;
animation-delay: 0.2s;
}
.sk-cube-grid .sk-cube2 {
-webkit-animation-delay: 0.3s;
animation-delay: 0.3s;
}
.sk-cube-grid .sk-cube3 {
-webkit-animation-delay: 0.4s;
animation-delay: 0.4s;
}
.sk-cube-grid .sk-cube4 {
-webkit-animation-delay: 0.1s;
animation-delay: 0.1s;
}
.sk-cube-grid .sk-cube5 {
-webkit-animation-delay: 0.2s;
animation-delay: 0.2s;
}
.sk-cube-grid .sk-cube6 {
-webkit-animation-delay: 0.3s;
animation-delay: 0.3s;
}
.sk-cube-grid .sk-cube7 {
-webkit-animation-delay: 0s;
animation-delay: 0s;
}
.sk-cube-grid .sk-cube8 {
-webkit-animation-delay: 0.1s;
animation-delay: 0.1s;
}
.sk-cube-grid .sk-cube9 {
-webkit-animation-delay: 0.2s;
animation-delay: 0.2s;
}
@-webkit-keyframes sk-cubeGridScaleDelay {
0%, 35% {
background-color: #36393e;
}
0%, 70%, 100% {
-webkit-transform: scale3D(1, 1, 1);
transform: scale3D(1, 1, 1);
}
35% {
-webkit-transform: scale3D(0, 0, 1);
transform: scale3D(0, 0, 1);
}
100% {
background-color: #3d90ce;
}
}
}
</style>
@@ -0,0 +1,124 @@
<template>
<div class="content">
<ClosePageButton />
<div class="container-fluid lens-workspaces">
<div class="header sticky-top">
<h2><i class="material-icons">layers</i> Workspaces</h2>
</div>
<div class="row">
<div class="col-3" />
<div class="col-6">
<h2>Edit Workspace</h2>
<b-form @submit.prevent="updateWorkspace">
<b-form-group
label="Name:"
label-for="input-name"
>
<b-form-input
id="input-name"
v-model="workspace.name"
trim
/>
</b-form-group>
<b-form-group
label="Description:"
label-for="input-description"
>
<b-form-input
id="input-description"
v-model="workspace.description"
trim
/>
</b-form-group>
<b-form-row>
<b-col>
<b-button variant="primary" type="submit">
Save
</b-button>
<b-button v-b-modal.bv-modal-confirm>
Delete
</b-button>
<b-modal id="bv-modal-confirm" @ok="deleteWorkspace" ok-title="Delete" ok-variant="danger" title-class="confirm-header" hide-backdrop title="Confirm workspace delete">
<p>Are you sure you want to delete <strong>{{ workspace.name }}</strong> workspace from Lens?</p>
</b-modal>
</b-col>
</b-form-row>
</b-form>
</div>
<div class="col-3" />
</div>
</div>
</div>
</template>
<script>
import ClosePageButton from "@/components/common/ClosePageButton";
export default {
name: 'EditWorkspacePage',
components: {
ClosePageButton
},
data() {
return {
errors: {
name: null,
description: null
}
}
},
computed: {
workspace: function() {
return this.$store.getters.workspaceById(this.$route.params.id)
}
},
methods: {
updateWorkspace: function() {
this.$store.commit("updateWorkspace", this.workspace)
this.$router.push({
name: "workspaces-page"
})
},
deleteWorkspace: async function() {
await this.$store.commit("removeWorkspace", this.workspace)
this.$router.push({
name: "workspaces-page"
})
}
},
mounted: function() {
this.$store.commit("hideMenu");
},
destroyed: function() {
this.$store.commit("showMenu");
}
}
</script>
<style lang="scss" scoped>
#app > .main-view > .content {
left: 70px;
right: 70px;
}
h2 {
i {
position: relative;
top: 6px;
}
}
.lens-workspaces {
height: 100%;
overflow-y: scroll;
& input {
background-color: #252729 !important;
border: 0px !important;
color: #87909c !important;
}
.header {
padding-top: 15px;
}
}
</style>
+71
View File
@@ -0,0 +1,71 @@
<template>
<div class="content">
<div class="landing-page">
<b-container fluid class="h-100">
<!-- wrapper for center/center align -->
<b-row class="h-100">
<b-col sm="12" class="my-auto">
<div>
<b-row align-h="center" class="banner">
<b-col sm="8">
<h1 v-if="user && clusters.length === 0" class="display-4 text-center">
Welcome, {{ greetingName }}!
</h1>
<p v-if="clusters.length === 0" class="text-center">
Get started by associating one or more clusters to Kontena Lens.
</p>
</b-col>
</b-row>
</div>
</b-col>
</b-row>
</b-container>
</div>
</div>
</template>
<script>
export default {
name: 'LandingPage',
components: {},
methods: {},
computed: {
workspaces: function() {
return this.$store.getters.workspaces;
},
clusters: function() {
return this.$store.getters.clusters;
},
user: function() {
return this.$store.getters.user;
},
greetingName: function() {
return (this.user['first-name'] && this.user['first-name'] !== "") ? this.user['first-name'] : this.user.username
}
}
}
</script>
<style scoped lang="scss">
.landing-page{
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background: #282b2f url(../assets/img/crane.svg) no-repeat;
background-position: 0px 35%;
background-size: 85%;
background-clip: content-box;
.banner {
h1 {
text-align: center;
font-size: 28px;
font-weight: normal;
}
p {
font-size: 14px;
}
}
}
</style>
@@ -0,0 +1,72 @@
<template>
<span>
<a
id="add-cluster"
class="add-cluster menu-item"
:class="[{
'active':isActive(),
}]"
@click.prevent="addCluster"
v-b-tooltip.hover.right
title="Add clusters"
>
<i class="material-icons">add_box</i>
<span v-if="newContexts.length > 0" class="badge badge-success">{{ newContexts.length }}</span>
</a>
</span>
</template>
<script>
import ClustersMixin from "@/mixins/ClustersMixin";
import { newContexts } from '@kubernetes/client-node/dist/config_types';
export default {
name: "AddClusterMenuItem",
mixins: [ClustersMixin],
data(){
return {
}
},
computed: {
},
methods: {
isActive: function( id ){
return "new" === this.$route.params.id;
},
addCluster: async function(){
this.$router.push({
name: "add-cluster-page",
params: {
id: "new"
},
}).catch(err => {})
}
},
mounted() {
console.log(this.newContexts.length)
this.$store.dispatch("reloadAvailableKubeContexts");
}
}
</script>
<style scoped lang="scss">
.add-cluster{
position: relative;
display: block;
color: #fff;
padding: 6px 8px 6px 9px;
opacity: 0.4;
cursor: pointer;
&.active{
opacity: 0.75;
}
i {
font-size: 50px;
}
.badge {
position: absolute;
bottom: 10px;
right: 10px;
}
}
</style>
@@ -0,0 +1,166 @@
<template>
<a
class="menu-item"
:class="[{
'active':isActive,
'online':(activated && !isActive),
}]"
@click.prevent="openClusterPage()"
@contextmenu.prevent="openContextMenu()"
:title="preferences.clusterName"
v-b-tooltip.hover.right
>
<div
class="icon-frame"
:class="[{
'active':isActive
}]"
>
<img v-if="preferences.icon" :src="preferences.icon">
<hashicon v-else :name="preferences.clusterName" size="38" />
<span v-if="isAdmin && eventCount > 0" class="badge badge-danger">{{ eventCount >= 1000 ? "1000+" : eventCount }}</span>
</div>
</a>
</template>
<script>
import hashicon from "../hashicon/hashicon";
import { setInterval, clearInterval } from "timers";
const { remote } = require('electron')
const { Menu, MenuItem } = remote
export default {
name: "ClusterMenuItem",
components: {
hashicon
},
data(){
return {
eventPoller: null,
eventCount: 0,
activated: false
}
},
props: {
cluster: {
type: Object,
required: true,
default: null
},
},
computed: {
preferences: function() {
return this.cluster.preferences;
},
clusterAccessible: function() {
return this.$store.getters.clusterById(this.cluster.id).accessible;
},
isActive: function() {
return this.cluster.id === this.$route.params.id;
},
isAdmin: function() {
return this.cluster.isAdmin;
}
},
methods: {
openClusterPage() {
this.activated = true;
this.$router.push({
name: "cluster-page",
params: {
id: this.cluster.id
},
}).catch(err => {})
},
openContextMenu(){
const self = this;
const menu = new Menu()
menu.append(new MenuItem({ label: 'Settings', click() {
self.$router.push({
name: "cluster-settings-page",
params: {
id: self.cluster.id
},
}).catch(err => {})
} }))
if (this.activated) {
menu.append(new MenuItem({ label: 'Disconnect', click() {
self.activated = false;
self.stopPolling();
self.$store.dispatch("stopCluster", self.cluster.id);
if (self.isActive) {
self.$router.push({
name: "landing-page"
}).catch(err => {})
}
} }))
}
menu.popup({ window: remote.getCurrentWindow() })
},
toggleEventPolling: function() {
if(this.clusterAccessible && !this.eventPoller && this.activated && !this.isActive) {
this.fetchEvents()
this.eventPoller = setInterval(async () => {
await this.fetchEvents()
}, 30 * 1000);
} else {
this.eventCount = 0;
this.stopPolling()
}
},
fetchEvents: async function() {
try {
this.eventCount = await this.$promiseIpc.send("getClusterEvents", this.cluster.id);
} catch (error) {
console.error("Failed to get event count for cluster:", error)
}
},
stopPolling: function() {
if (this.eventPoller) {
clearInterval(this.eventPoller)
this.eventPoller = null
}
}
},
watch: {
clusterAccessible: "toggleEventPolling",
isActive: "toggleEventPolling"
},
beforeDestroy() {
this.stopPolling();
}
}
</script>
<style scoped lang="scss">
.badge {
position: absolute;
bottom: 10px;
right: 10px;
padding: 4px;
i {
font-size: 8px;
}
}
img {
width: 38px;
max-height: 42px;
}
span.hashicon {
margin-top: 6px;
}
div.icon-frame {
width: 44px;
height: 44px;
display: flex;
align-items: center;
&.active, &:hover {
margin-left: -3px;
padding: 0px 0px 0px 3px;
background-color: #f8f9fa;
border-radius: 3px;
}
}
</style>
@@ -0,0 +1,100 @@
<template>
<div class="main-menu">
<draggable v-model="clusters">
<ClusterMenuItem
v-for="cluster in clusters" :key="cluster.id"
:cluster="cluster"
/>
</draggable>
<AddClusterMenuItem />
<b-tooltip v-if="clusters.length === 0" show target="add-cluster" placement="rightbottom">
<p class="text-left">
This is the quick launch menu.
</p>
<p class="text-left">
Associate clusters and choose the ones you want to access via quick launch menu by clicking the + button.
</p>
</b-tooltip>
</div>
</template>
<script>
import ClusterMenuItem from "@/components/MainMenu/ClusterMenuItem";
import AddClusterMenuItem from "@/components/MainMenu/AddClusterMenuItem";
import draggable from 'vuedraggable'
import { clusterStore } from "@/../common/cluster-store";
const { remote } = require('electron')
const { Menu, MenuItem } = remote
export default {
name: "MainMenu",
components: {
ClusterMenuItem,
AddClusterMenuItem,
draggable
},
data(){
return {
}
},
computed : {
menuAvailable: function(){
return this.$store.getters.isLoggedIn && this.$store.getters.isEulaAccepted
},
clusters: {
get: function() {
return this.$store.getters.clusters;
},
set: function(clusters) {
this.$store.commit("updateClusters", clusters);
clusterStore.storeClusters(clusters);
}
}
},
methods: {
isActive: function( id ){
return id === this.$route.params.id;
},
addCluster: async function(){
this.$router.push({
name: "add-cluster-page",
params: {
id: "new"
},
}).catch(err => {})
}
}
}
</script>
<style scoped lang="scss">
.main-menu {
position: absolute;
left: 0;
top: 0;
bottom: 20px;
width: 70px;
padding-top: 25px;
background: #252729;
overflow: hidden;
z-index: 1000;
}
.menu-item {
position: relative;
cursor: pointer;
display: block;
padding: 6px 15px 6px 15px;
color: #87909c;
opacity: 0.3;
&.active {
opacity: 1;
}
&.online {
opacity: 1;
}
&:hover {
opacity: 1;
}
}
</style>
+289
View File
@@ -0,0 +1,289 @@
<template>
<div class="content">
<ClosePageButton />
<div class="container-fluid lens-preferences">
<div class="header sticky-top">
<h2>Preferences</h2>
</div>
<!-- <div class="row">
<div class="col-3" />
<div ref="content" class="global-settings col-6">
<h2>Kontena Lens PRO License</h2>
<b-form-group
label="No Kontena Lens PRO license assigned. Assign license by entering license key below:"
label-for="input-license-key"
v-if="!licenceData || licenceData.status !== 'valid'"
>
<b-form-input
id="input-license-key"
v-model="licenseKey"
trim
@blur="onLicenseKeySave"
placeholder="Enter license key"
:disabled="!isLoggedIn"
:state="errors.licenseKey"
/>
</b-form-group>
<div v-if="licenceData">
<p v-if="licenceData.status === 'valid'">
Valid Kontena Lens PRO license assigned. Valid until {{ licenceData.valid_until }}<span v-if="licenceData.recurring">(renews automatically)</span>.
</p>
</div>
</div>
<div class="col-3" />
</div> -->
<div class="row">
<div class="col-3" />
<div ref="content" class="global-settings col-6">
<h2>Color Theme</h2>
<b-form-group
label="Cluster Color theme:"
label-for="input-color-theme"
>
<b-form-select
id="input-color-theme"
v-model="preferences.colorTheme"
@change="onSave"
:options="colorThemeOptions"
/>
</b-form-group>
</div>
<div class="col-3" />
</div>
<div class="row">
<div class="col-3" />
<div ref="content" class="global-settings col-6">
<h2>Helm</h2>
<b-form-group
label="Repositories:"
label-for="textarea-helm-repositories"
>
<b-input-group>
<b-form-select
:options="hubRepositories"
v-model="selectedHubRepo"
>
<template v-slot:first>
<option value="">
Select Helm repository
</option>
</template>
</b-form-select>
<b-input-group-append>
<b-button :disabled="!selectedHubRepo" @click="addHubRepo" variant="primary">
Add <b-spinner small v-if="isHelmProcessing" label="Small Spinner" />
</b-button>
</b-input-group-append>
</b-input-group>
</b-form-group>
<b-list-group>
<b-list-group-item v-for="repo in helmRepos" :key="repo.name">
<span v-b-tooltip.hover v-b-tooltip.right :title="repo.url">{{ repo.name }}</span>
<button @click="removeRepo(repo)" :data-repo="repo.name" class="close">
<i class="material-icons">delete</i>
</button>
</b-list-group-item>
</b-list-group>
</div>
<div class="col-3" />
</div>
<div class="row">
<div class="col-3" />
<div ref="content" class="global-settings col-6">
<h2>HTTP Proxy</h2>
<b-form-group
label="Proxy URL:"
label-for="input-https-proxy"
description="Proxy is used only for non-cluster communication."
>
<b-form-input
id="input-https-proxy"
v-model="preferences.httpsProxy"
trim
@blur="onSave"
placeholder="Type HTTP proxy url (example: http://proxy.acme.org:8080)"
/>
</b-form-group>
<h2>Certificate Trust</h2>
<b-form-group
label-for="checkbox-allow-untrusted-cas"
description="This will make Lens to trust ANY certificate authority without any validations. Needed with some corporate proxies that do certificate re-writing. Does not affect cluster communications!"
>
<b-form-checkbox
id="checkbox-allow-untrusted-cas"
switch
v-model="preferences.allowUntrustedCAs"
@input="onSave"
>
Allow untrusted Certificate Authorities
</b-form-checkbox>
</b-form-group>
<h2>Telemetry & Usage Tracking</h2>
<b-form-group
label-for="checkbox-allow-telemetry"
description="Telemetry & usage data is collected to continuously improve the Kontena Lens experience."
>
<b-form-checkbox
id="checkbox-allow-telemetry"
switch
v-model="preferences.allowTelemetry"
:disabled="licenceData && licenceData.status === 'valid'"
@input="onSave"
>
Allow telemetry & usage tracking
</b-form-checkbox>
</b-form-group>
</div>
<div class="col-3" />
</div>
</div>
</div>
</template>
<script>
import * as request from 'request-promise-native'
import { globalRequestOpts } from "../../common/request"
import ClosePageButton from "@/components/common/ClosePageButton";
export default {
name: 'PreferencesPage',
components: {
ClosePageButton
},
data(){
return {
licenseKey: "",
hubRepositories: [],
selectedHubRepo: "",
colorThemeOptions: [
{ value: "dark", text: "Dark" },
{ value: "light", text: "Light" },
],
errors: {
licenseKey: null
},
isHelmProcessing: null,
}
},
computed: {
helmRepos: function() {
return this.$store.getters.repos;
},
preferences: function() {
return this.$store.getters.preferences;
},
licenceData: function() {
return this.$store.getters.licenseData;
},
isLoggedIn: function() {
console.log(this.$store.getters.isLoggedIn)
return this.$store.getters.isLoggedIn;
}
},
methods: {
onSave: function() {
this.$store.commit("savePreferences", this.preferences);
},
onLicenseKeySave: async function() {
if (this.licenseKey !== "") {
try {
await this.$store.dispatch("exhangeKey", {
key: this.licenseKey,
user: this.$store.getters.user
})
this.errors.licenseKey = true;
} catch(error) {
this.errors.licenseKey = false;
}
} else {
this.errors.licenseKey = null;
}
},
request: async function(opts) {
const o = Object.assign({
timeout: 10000,
headers: {},
resolveWithFullResponse: true,
json: true
}, globalRequestOpts(opts))
return await request(o)
},
repoCompare: function( a, b ) {
if ( a.name < b.name ){
return -1;
}
if ( a.name > b.name ){
return 1;
}
return 0;
},
addHubRepo: async function() {
if(!this.selectedHubRepo || this.selectedHubRepo === "") {
return
}
this.isHelmProcessing = true
const [name, url ] = this.selectedHubRepo.split("|")
await this.$store.dispatch('addHelmRepo', { name, url })
this.selectedHubRepo = ""
this.isHelmProcessing = false
},
removeRepo: async function(repo) {
await this.$store.dispatch('removeHelmRepo', repo)
},
loadHubRepositories: async function() {
const res = await this.request({ uri: "https://hub.helm.sh/assets/js/repos.json"}).catch((error) => { this.hubRepositories = [] })
this.hubRepositories = res.body.data.sort(this.repoCompare).map((repo) => {
return { text: repo.name, value: repo.name+"|"+repo.url}
})
}
},
created() {
this.$store.dispatch('refreshHelmRepos')
},
mounted: async function() {
this.$store.commit("hideMenu");
this.loadHubRepositories()
},
destroyed: function() {
this.$store.commit("showMenu");
}
}
</script>
<style lang="scss" scoped>
#app > .main-view > .content {
left: 70px;
right: 70px;
}
.lens-preferences {
height: 100%;
overflow-y: scroll;
& input {
background-color: #252729 !important;
border: 0px !important;
color: #87909c !important;
}
.header {
padding-top: 15px;
}
.close {
text-shadow: none;
}
.list-group-item {
background-color: #252729 !important;
color: #87909c !important;
}
& i {
color: #87909c !important;
}
}
.global-settings {
padding-top: 0.75em;
}
</style>
+112
View File
@@ -0,0 +1,112 @@
<template>
<div class="content">
<div class="container-fluid whats-new">
<div class="row">
<div class="col-md whats-new-text-wrapper">
<div class="logo">
<img src="../assets/img/lens-logo.svg">
</div>
<!-- Safe to use v-html as we self generate the html locally only -->
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="whats-new-text" v-html="content">
<!-- What's new content -->
</div>
</div>
</div>
<div class="row">
<div class="col-md whats-new-actions-wrapper">
<div class="whats-new-actions">
<button type="button" class="btn btn-primary" @click="toLanding">
Ok, got it!
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import marked from 'marked'
import fs from 'fs'
import * as path from "path"
export default {
name: 'WhatsNewPage',
data() {
return {
error: "",
// eslint-disable-next-line
content: marked(fs.readFileSync(path.join(__static, "RELEASE_NOTES.md"), 'utf8')),
}
},
components: {},
computed: {
},
methods: {
toLanding: async function() {
await this.$store.dispatch("updateLastSeenAppVersion")
this.$router.push({
name: "landing-page",
}).catch(err => {})
},
},
}
</script>
<style lang="scss" scoped>
.content {
background: #282b2f url(../assets/img/crane.svg) no-repeat;
background-position: 0px 35%;
background-size: 85%;
background-clip: content-box;
}
.content > .whats-new{
width: 100%;
height: 100%;
background-color:rgba(0, 0, 0, 0.3);
.whats-new-text-wrapper{
position: absolute;
top: 20px;
bottom: 77px;
left: 0;
right: 0;
overflow-y: scroll;
.logo {
padding: 30px 30px;
max-width: 1000px;
img {
width: 200px;
}
}
.whats-new-text{
padding: 10px 30px;
max-width: 1000px;
h1{
font-size: 24px;
}
h2{
padding: 0;
}
}
}
.whats-new-actions-wrapper{
position: absolute;
height: 77px;
left: 0;
right: 0;
bottom: 0;
border-top: 1px solid #333;
background: var(--lens-pane-bg);
.whats-new-actions{
padding: 20px;
text-align: center;
}
}
}
</style>
+156
View File
@@ -0,0 +1,156 @@
<template>
<div class="content">
<ClosePageButton />
<div class="container-fluid lens-workspaces">
<div class="header sticky-top">
<h2><i class="material-icons">layers</i> Workspaces</h2>
</div>
<div class="row">
<div class="col-9 main-content">
<b-list-group>
<b-list-group-item @click="editWorkspace(workspace)" href="#" v-for="workspace in workspaces" :key="workspace.id" :workspace="workspace">
<div class="d-flex w-100 justify-content-between">
<h5>{{ workspace.name }}</h5>
<small v-if="workspace.id !== 'default'">
<a href="#" title="Switch to workspace" v-b-tooltip.hover.right @click.prevent.stop="switchWorkspace(workspace)"><i class="material-icons">keyboard_arrow_right</i></a>
</small>
</div>
<p class="mb-1">
{{ workspace.description }}
</p>
</b-list-group-item>
</b-list-group>
<b-button variant="primary" href="#" @click.prevent="goAdd">
Add Workspace
</b-button>
</div>
<div class="col-3 help">
<h3>What is a Workspace?</h3>
<p>
Workspaces are used to organize number of clusters into logical groups. A single workspaces contains a list of clusters and their full configuration.
</p>
</div>
</div>
</div>
</div>
</template>
<script>
import ClosePageButton from "@/components/common/ClosePageButton";
export default {
name: 'WorkspacesPage',
components: {
ClosePageButton
},
data(){
return {
fields: [
{
key: "name",
label: "Name"
},
{
key: "description",
label: "Description"
}
],
errors: {
}
}
},
computed: {
workspaces: function() {
return this.$store.getters.workspaces;
}
},
methods: {
switchWorkspace: function(workspace) {
this.$store.commit("setCurrentWorkspace", workspace);
this.$store.dispatch("clearClusters");
this.$store.dispatch("refreshClusters", workspace);
this.$router.push({
name: "landing-page"
});
},
editWorkspace: function(workspace) {
this.$router.push({
name: "edit-workspace-page",
params: {
id: workspace.id
}
});
},
goAdd: function() {
this.$router.push({
name: "add-workspace-page"
});
}
},
mounted: function() {
this.$store.commit("hideMenu");
},
destroyed: function() {
this.$store.commit("showMenu");
}
}
</script>
<style lang="scss" scoped>
#app > .main-view > .content {
left: 70px;
right: 70px;
}
h2 {
i {
position: relative;
top: 6px;
}
}
.main-content {
padding-left: 40px;
padding-top: 20px;
}
.lens-workspaces {
height: 100%;
overflow-y: scroll;
& input {
background-color: #252729 !important;
border: 0px !important;
color: #87909c !important;
}
.header {
padding-top: 15px;
}
.list-group {
padding-bottom: 20px;
}
.list-group-item {
background-color: inherit;
color: var(--lens-text-color-light);
border: 0;
border-bottom: 1px solid var(--lens-pane-bg);
padding-left: 0;
h5 {
color: var(--kontena-blue)
}
h5:hover {
text-decoration: underline;
}
small {
a {
color: var(--lens-text-color-light);
z-index: 100;
}
i {
opacity: 0.3;
&:hover {
opacity: 1;
}
}
}
}
}
</style>
@@ -0,0 +1,40 @@
<template>
<div class="close-page-button">
<a @click.prevent="navigateBack" href="#">
<i class="material-icons">close</i>
</a>
</div>
</template>
<script>
export default {
name: "ClosePageButton",
data() {
return {}
},
methods: {
navigateBack: () => {
window.history.back();
}
}
}
</script>
<style scoped lang="scss">
div.close-page-button {
position: absolute;
top: 20px;
right: 10px;
z-index: 1100;
a {
position: relative;
display: block;
color: #fff;
opacity: 0.4;
}
i {
font-size: 30px;
}
}
</style>
@@ -0,0 +1,28 @@
<template>
<span class="hashicon" ref="hashicon" />
</template>
<script>
import hashicon from 'hashicon'
export default {
name:'Hashicon',
props:{
name: {
default: null,
type: String
},
size: {
default: null,
type: String
},
},
mounted() {
const params = {
size: this.size || 48,
lightness: { min: 45, max: 65 },
saturation: { min: 30, max: 60 }
}
this.$refs.hashicon.appendChild(hashicon(this.name, params))
}
}
</script>
+84
View File
@@ -0,0 +1,84 @@
import "../common/system-ca"
import Vue from 'vue'
import BootstrapVue from 'bootstrap-vue'
import App from './App'
import router from './router'
import store from './store'
import { PromiseIpc } from 'electron-promise-ipc'
import ElectronStore from 'electron-store'
import { Tracker } from "../common/tracker"
import { remote } from "electron"
import Clipboard from 'v-clipboard'
//load prism somewhere
import "prismjs";
import "prismjs/components/prism-yaml"
import "prismjs/themes/prism-tomorrow.css";
//vue-prism-editor dependency
import "vue-prism-editor/dist/VuePrismEditor.css";
Vue.use(BootstrapVue)
Vue.use(Clipboard)
import "./assets/css/app.scss"
const persist = new ElectronStore();
const promiseIpc = new PromiseIpc( { maxTimeoutMs: 6000 } );
const tracker = new Tracker(remote.app);
promiseIpc.on('logout', async (_) => {
await store.dispatch('logout');
if(router.currentRoute.name !== 'login-page') {
router.push({name: 'login-page'});
}
});
promiseIpc.on('navigate', async ( view ) => {
router.push( view ).catch(err => {})
});
/**
* Generic store related IPC handler
* actions in form of:
* {
* action: "foobar",
* data: {
* someValue: 42,
* otherValue: "foobar"
* }
* }
* where:
* - action: the store action name
* - data: the data obect passed as argument to the store action
*/
promiseIpc.on('store-dispatch', async (action) => {
store.dispatch(action.action, action.data)
});
if (!process.env.IS_WEB) Vue.use(require('vue-electron'))
Vue.config.productionTip = false
Vue.mixin({
created: function () {
this.$persist = persist;
this.$promiseIpc = promiseIpc;
this.$tracker = tracker;
}
})
// any initialization we want to do for app state
setTimeout(() => {
store.dispatch('init', ).catch((error) => {
console.error(error)
}).finally(() => {
/* eslint-disable no-new */
console.log("start vue")
new Vue({
components: { App },
persist,
store,
router,
template: '<App/>'
}).$mount('#app')
})
}, 0)
+16
View File
@@ -0,0 +1,16 @@
export default {
computed: {
clusters: function() {
return this.$store.getters.clusters
},
newContexts: function() {
const seenContexts = this.seenContexts || this.$store.getters.seenContexts
const contextNamesFromKubeconfig = this.availableContexts.map(item => item.currentContext)
return contextNamesFromKubeconfig.filter((item) => seenContexts.indexOf(item) < 0)
},
availableContexts: function() {
// read available kubeconfigs from store on filter out configs already found in added clusters
return this.$store.getters.availableKubeContexts.filter(item => !this.clusters.find((cluster) => cluster.contextName == item.currentContext));
},
}
}
+99
View File
@@ -0,0 +1,99 @@
import Vue from 'vue'
import Router from 'vue-router'
import store from "../store/index.js";
// Route Guard
import * as guard from './routeguard'
Vue.use(Router)
const router = new Router({
routes: [
{
path: '/',
name: 'landing-page',
component: require('@/components/LandingPage').default,
meta: {
routeguard: [
// guards in priority order; the first one to catch will trigger something
guard.whatsNew,
],
}
},
{
path: '/preferences',
name: 'preferences-page',
component: require('@/components/PreferencesPage').default,
},
{
path: '/workspaces',
name: 'workspaces-page',
component: require('@/components/WorkspacesPage').default,
props: true,
},
{
path: '/add-workspace',
name: 'add-workspace-page',
component: require('@/components/AddWorkspacePage').default,
props: true,
},
{
path: '/edit-workspace',
name: 'edit-workspace-page',
component: require('@/components/EditWorkspacePage').default,
props: true,
},
{
path: '/clusters/:id',
name: 'cluster-page',
component: require('@/components/ClusterPage').default,
props: true,
},
{
path: '/clusters/:id/settings',
name: 'cluster-settings-page',
component: require('@/components/ClusterSettings').default,
props: true,
},
{
path: "/add-cluster",
name: "add-cluster-page",
component: require('@/components/AddClusterPage').default,
props: true,
},
{
path: "/whats-new",
name: "whats-new-page",
component: require('@/components/WhatsNewPage').default,
props: true,
},
{
path: '*',
redirect: '/'
}
]
})
router.beforeEach((to, from, next) => {
// guard routes
if(to.meta && to.meta.routeguard && to.meta.routeguard.length > 0){
let guardNext;
to.meta.routeguard.forEach(guard => {
if(!guardNext) guardNext = guard(to, from, store);
});
if(guardNext) {
next(guardNext);
} else {
next();
}
}
next();
});
export default router;
+26
View File
@@ -0,0 +1,26 @@
export function auth ( to, from, store ) {
if(!store.getters.isLoggedIn){
console.log("router: guard: auth: activated");
return {
path: '/login'
}
}
}
export function eula ( to, from, store ) {
if(!store.getters.isEulaAccepted){
console.log("router: guard: eula: activated");
return {
path: '/accept-eula'
}
}
}
export function whatsNew( to, from, store ) {
if(store.getters.showWhatsNew){
console.log("router: guard: whatsNew: activated");
return {
path: '/whats-new'
}
}
}
+89
View File
@@ -0,0 +1,89 @@
import Vue from 'vue'
import Vuex from 'vuex'
import { userStore } from "../../common/user-store"
import KubeContexts from './modules/kube-contexts'
import Clusters from './modules/clusters'
import HelmRepos from './modules/helm-repos'
import Workspaces from './modules/workspaces'
import * as semver from "semver"
import * as appUtil from "../../common/app-utils"
// promise ipc
import { PromiseIpc } from 'electron-promise-ipc'
const promiseIpc = new PromiseIpc( { maxTimeoutMs: 120000 } );
// tracker
import { Tracker } from "../../common/tracker"
import { remote } from "electron"
const tracker = new Tracker(remote.app);
Vue.use(Vuex);
export default new Vuex.Store({
modules: {
Clusters,
HelmRepos,
KubeContexts,
Workspaces
},
state: {
preferences: {},
hud: {
isMenuVisible: true,
},
seenContexts: userStore.getSeenContexts(),
lastSeenAppVersion: userStore.lastSeenAppVersion(),
},
mutations: {
storeSeenContexts(state, context) {
const seenContexts = userStore.storeSeenContext(context);
state.seenContexts = seenContexts
},
updateLastSeenAppVersion(state, appVersion) {
state.lastSeenAppVersion = appVersion;
userStore.setLastSeenAppVersion(appVersion)
},
loadPreferences(state) {
this.commit("savePreferences", userStore.getPreferences());
},
savePreferences(state, prefs) {
state.preferences = prefs;
userStore.setPreferences(prefs);
this.dispatch("destroyWebviews")
promiseIpc.send("preferencesSaved")
},
hideMenu(state) {
state.hud.isMenuVisible = false;
},
showMenu(state) {
state.hud.isMenuVisible = true;
}
},
actions: {
async init({commit, getters}) {
commit("loadPreferences");
await this.dispatch('refreshClusters', getters.currentWorkspace);
return true;
},
async addSeenContexts({commit}, data){
commit('storeSeenContexts', data);
},
async updateLastSeenAppVersion({commit, state}) {
tracker.event("app", "whats-new-seen")
commit("updateLastSeenAppVersion", appUtil.getAppVersion())
}
},
getters : {
seenContexts: state => state.seenContexts,
hud: state => state.hud,
isMenuVisible: function(state, getters){
return state.hud.isMenuVisible && !getters.showWhatsNew;
},
showWhatsNew: function(state) {
return semver.gt(appUtil.getAppVersion(), state.lastSeenAppVersion);
},
preferences: state => state.preferences,
}
});
+266
View File
@@ -0,0 +1,266 @@
import Vue from "vue"
import { ClusterInfo } from "../../../main/cluster"
import { MutationTree, ActionTree, GetterTree } from "vuex"
import { PromiseIpc } from 'electron-promise-ipc'
import { Tracker } from "../../../common/tracker"
import { remote } from "electron"
import { clusterStore } from "../../../common/cluster-store"
import { Workspace } from "../../../common/workspace-store"
const promiseIpc = new PromiseIpc( { maxTimeoutMs: 120000 } );
const tracker = new Tracker(remote.app);
export interface LensWebview {
id: string;
loaded: boolean;
webview?: any;
}
export interface ClusterState {
lenses: LensWebview[];
clusters: ClusterInfo[];
}
const state: ClusterState = {
lenses: [],
clusters: []
}
const actions: ActionTree<ClusterState, any> = {
async refreshClusters({commit}, currentWorkspace: Workspace) {
const clusters: ClusterInfo[] = await promiseIpc.send('getClusters', currentWorkspace.id).catch((error: Error) => {
return false;
})
if(!clusters) return false;
commit('updateClusters', clusters);
clusters.forEach((cluster: ClusterInfo) => {
const lens: LensWebview = {
id: cluster.id,
webview: null,
loaded: false
};
commit("updateLens", lens)
})
return true;
},
async getCluster({commit, getters}, id: string) {
const cluster: ClusterInfo = getters.clusters.find((c: ClusterInfo) => c.id === id)
if(!cluster) return null;
const remoteCluster = await promiseIpc.send("getCluster", cluster.id)
if(!remoteCluster) return null;
Object.assign(cluster, remoteCluster)
commit('updateCluster', cluster);
return cluster;
},
async refineCluster({commit}, id: string) {
console.log("VUEX: ACTION: REFINE CLUSTER", id);
const remoteCluster = await promiseIpc.send("getCluster", id)
if(!remoteCluster) return null;
commit('updateCluster', remoteCluster);
return remoteCluster;
},
async stopCluster({dispatch, getters}, id: string) {
const cluster: ClusterInfo = getters.clusters.find((c: ClusterInfo) => c.id === id)
if (!cluster) return;
const lens = getters.lensById(cluster.id)
if (lens) {
await dispatch("detachWebview", lens)
await promiseIpc.send("stopCluster", cluster.id)
tracker.event("cluster", "stop")
}
},
async removeCluster({getters, dispatch}, id: string) {
const cluster: ClusterInfo = getters.clusters.find((c: ClusterInfo) => c.id === id)
if (!cluster) {
return
}
const lens = this.getters.lensById(cluster.id)
if (lens) {
dispatch("detachWebview", lens)
}
await promiseIpc.send("removeCluster", cluster.id).catch((error: Error) => {
return false;
})
tracker.event("cluster", "remove´");
await dispatch("refreshClusters", getters.currentWorkspace)
return true;
},
async addCluster({commit, getters, dispatch}, data) {
const res = await promiseIpc.send("addCluster", data)
if(!res) return null;
tracker.event("cluster", "add");
commit('updateClusters', res.allClusters);
await dispatch("refreshClusters", getters.currentWorkspace);
return res.addedCluster;
},
async clearClusters({commit, getters, dispatch}){
// todo: clean from main process as well?
getters.lenses.forEach((lens: LensWebview) => {
if (lens.webview) {
dispatch("detachWebview", lens)
}
})
commit('updateLenses', []);
commit('updateClusters', []);
return true;
},
async uploadClusterIcon({commit}, data) {
const res = await promiseIpc.send("saveClusterIcon", data)
tracker.event("cluster", "upload-icon")
if (res.cluster) commit("updateCluster", res.cluster)
return res
},
async resetClusterIcon({commit}, data) {
const res = await promiseIpc.send("resetClusterIcon", data.clusterId)
tracker.event("cluster", "reset-icon")
if (res.cluster) commit("updateCluster", res.cluster)
return res
},
// For data structure see: cluster-manager.ts / FeatureInstallRequest
async installClusterFeature({commit}, data) {
// Custom no timeout IPC as install can take very variable time
const ipc = new PromiseIpc();
const response = await ipc.send('installFeature', data)
console.log("installer result:", response);
const cluster = await ipc.send('refreshCluster', data.clusterId)
tracker.event("cluster", "install-feature")
commit("updateCluster", cluster)
return response
},
// For data structure see: cluster-manager.ts / FeatureInstallRequest
async uninstallClusterFeature({commit}, data) {
// Custom no timeout IPC as uninstall can take very variable time
const ipc = new PromiseIpc();
const response = await ipc.send('uninstallFeature', data)
console.log("uninstaller result:", response);
const cluster = await ipc.send('refreshCluster', data.clusterId)
tracker.event("cluster", "uninstall-feature")
commit("updateCluster", cluster)
return response
},
attachWebview({commit}, lens: LensWebview) {
const container: any = document.getElementById("lens-container");
if (!container || !lens.webview) {
return
}
container.style = "display: block;"
let webview = null
container.childNodes.forEach((child: any) => {
if (child === lens.webview) {
webview = child
}
})
if (!webview) {
container.appendChild(lens.webview)
}
container.childNodes.forEach((child: any) => {
if (child !== lens.webview) {
child.style = "display: none;"
} else {
child.style = "top: 0; bottom: 20px; position: absolute; width: 100%;"
}
})
promiseIpc.send("enableClusterSettingsMenuItem", lens.id)
},
detachWebview({commit}, lens: LensWebview) {
const container: any = document.getElementById("lens-container");
if (!container) { return }
container.childNodes.forEach((child: any) => {
if (child === lens.webview) {
container.removeChild(lens.webview)
lens.webview = null
lens.loaded = false
commit("updateLens", lens)
}
})
promiseIpc.send("disableClusterSettingsMenuItem")
},
hideWebviews({commit}) {
const container: any = document.getElementById("lens-container");
if (!container) { return }
container.style = "display: none;"
container.childNodes.forEach((child: any) => {
child.style = "display: none;"
})
promiseIpc.send("disableClusterSettingsMenuItem")
},
destroyWebviews({commit}) {
state.lenses.forEach((lens) => {
this.dispatch("detachWebview", lens)
})
},
storeCluster({commit}, cluster: ClusterInfo) {
clusterStore.storeCluster(cluster);
commit("updateCluster", cluster)
promiseIpc.send("clusterStored", cluster.id)
}
}
const getters: GetterTree<ClusterState, any> = {
clusters: state => state.clusters,
clusterById: state => (id: string) => {
const cluster = state.clusters.find(c => c.id === id);
if (cluster) {
return cluster;
} else {
return null;
}
},
lenses: state => state.lenses,
lensById: state => (id: string) => {
const lens = state.lenses.find(c => c.id === id);
if (lens) {
return lens;
} else {
return null;
}
},
}
const mutations: MutationTree<ClusterState> = {
updateClusters(state, clusters: ClusterInfo[]) {
Vue.set(state, 'clusters', [...clusters])
},
updateCluster(state, cluster) {
state.clusters.forEach((c, index) => {
if(c.id === cluster.id) {
Vue.set(state.clusters, index, cluster)
}
})
},
updateLenses(state, data) {
Vue.set(state, 'lenses', [...data])
},
updateLens(state, lens: LensWebview) {
const lensIndex = state.lenses.findIndex(l => l.id == lens.id);
if (lensIndex >= 0) {
state.lenses[lensIndex] = lens
Vue.set(state.lenses, lensIndex, lens)
} else {
console.log("update new lens")
state.lenses.push(lens)
}
}
}
export default {
namespaced: false,
state,
getters,
mutations,
actions
}
+54
View File
@@ -0,0 +1,54 @@
import Vue from "vue"
import { MutationTree, ActionTree, GetterTree } from "vuex"
import { HelmRepo, repoManager } from "../../../main/helm-repo-manager"
export interface HelmRepoState {
repos: HelmRepo[];
}
const state: HelmRepoState = {
repos: []
}
const actions: ActionTree<HelmRepoState, any> = {
async addHelmRepo({ commit }, data){
const res = await repoManager.addRepo(data).catch((error: Error) => {
return false;
})
if(!res) return false;
return await this.dispatch("refreshHelmRepos")
},
async removeHelmRepo({ commit }, data){
const res = await repoManager.removeRepo(data).catch((error: Error) => {
return false;
})
if(!res) return false;
return await this.dispatch("refreshHelmRepos")
},
async refreshHelmRepos({commit}){
const repos: HelmRepo[] = await repoManager.repositories().catch((error: Error) => {
return null;
})
if(!repos) return false;
commit('updateRepos', repos);
return true;
}
}
const getters: GetterTree<HelmRepoState, any> = {
repos: state => state.repos
}
const mutations: MutationTree<HelmRepoState> = {
updateRepos(state, repos: HelmRepo[]) {
Vue.set(state, 'repos', [...repos])
},
}
export default {
namespaced: false,
state,
getters,
mutations,
actions
}
@@ -0,0 +1,46 @@
import * as k8s from "@kubernetes/client-node"
import { splitConfig, dumpConfigYaml } from "../../../main/k8s"
const state = {
availableKubeContexts: []
}
const actions = {
reloadAvailableKubeContexts: ({commit}) => {
let kc = new k8s.KubeConfig();
try {
kc.loadFromDefault();
} catch (error) {
console.error("Failed to read default kubeconfig: " + error.message);
}
// Remove the default setup the client makes if it does not find anything in the default config
// See: https://github.com/kubernetes-client/javascript/blob/2fc8fbc956ca89bf425ca3ea045d46ee7b75296b/src/config.ts#L253
// It defaults to loadFromClusterAndUser() when no config file can be found
if(kc.currentContext === "loaded-context") {
kc = new k8s.KubeConfig();
}
commit("saveAvailableKubeContexts", splitConfig(kc))
}
}
const getters = {
availableKubeContexts: function(state){
return state.availableKubeContexts
}
}
const mutations = {
saveAvailableKubeContexts(state, contexts) {
state.availableKubeContexts = contexts
}
}
export default {
namespaced: false,
state,
getters,
mutations,
actions
}
+49
View File
@@ -0,0 +1,49 @@
import { MutationTree, ActionTree, GetterTree } from "vuex"
import { workspaceStore, Workspace, WorkspaceData } from "../../../common/workspace-store"
export interface WorkspaceState {
workspaces: Array<Workspace>;
currentWorkspace: Workspace;
}
const state: WorkspaceState = {
workspaces: workspaceStore.getAllWorkspaces(),
currentWorkspace: workspaceStore.getAllWorkspaces().find((w) => w.id === "default")
}
const actions: ActionTree<WorkspaceState, any> = {
}
const getters: GetterTree<WorkspaceState, any> = {
workspaces: state => state.workspaces,
currentWorkspace: state => state.currentWorkspace,
workspaceById: state => (id: string) => {
return state.workspaces.find((ws) => ws.id == id)
}
}
const mutations: MutationTree<WorkspaceState> = {
setCurrentWorkspace(state, workspace: Workspace) {
state.currentWorkspace = workspace
},
addWorkspace(state, workspace: WorkspaceData) {
workspaceStore.storeWorkspace(workspace)
state.workspaces = workspaceStore.getAllWorkspaces()
},
updateWorkspace(state, workspace: WorkspaceData) {
workspaceStore.storeWorkspace(workspace)
state.workspaces = workspaceStore.getAllWorkspaces()
},
removeWorkspace(state, workspace: Workspace) {
workspaceStore.removeWorkspace(workspace)
state.workspaces = workspaceStore.getAllWorkspaces()
}
}
export default {
namespaced: false,
state,
getters,
mutations,
actions
}