mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
project restructuring and initial refactoring
This commit is contained in:
1 parent
5af12bdbbd
commit
aa0eca2dc4
807 files changed
+11848
-22947
No files matched your search
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div id="app_vue">
|
||||
<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 "@/_vue/components/MainMenu/MainMenu";
|
||||
import BottomBar from "@/_vue/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>
|
||||
@@ -0,0 +1,203 @@
|
||||
|
||||
@import 'custom';
|
||||
@import 'fonts';
|
||||
@import '~bootstrap/scss/bootstrap';
|
||||
@import '~bootstrap-vue/src/index.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_vue {
|
||||
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,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%;
|
||||
}
|
||||
}
|
||||
|
||||
.draggable-top{
|
||||
-webkit-app-region: drag;
|
||||
left: 0;
|
||||
top: 0;
|
||||
position: absolute;
|
||||
height: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
box-shadow: inset 0 0 12px $lens-pane-bg;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
box-shadow: inset 0 0 12px $lens-pane-bg;
|
||||
}
|
||||
|
||||
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_vue
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// from Lens Dashboard
|
||||
$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;
|
||||
$lens-primary: #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};
|
||||
--lens-primary: #{$lens-primary};
|
||||
}
|
||||
|
||||
// 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: $lens-primary !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
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
@import "~typeface-roboto/index.css";
|
||||
@import "~material-design-icons/iconfont/material-icons.css";
|
||||
@@ -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 |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 497.43 228.97">
|
||||
<defs><style>.cls-1{fill:#3f90ce;}</style></defs>
|
||||
<title>Lens logo</title>
|
||||
<g>
|
||||
<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>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 242 KiB |
@@ -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 "@/_vue/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 "@/_vue/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_vue > .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(--lens-primary);
|
||||
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>
|
||||
@@ -0,0 +1,158 @@
|
||||
<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 "@/_vue/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);
|
||||
},
|
||||
// Called only when online state changes
|
||||
toggleLens: function() {
|
||||
if (!this.lens) { return }
|
||||
if (this.accessible) {
|
||||
setTimeout(this.activateLens, 0); // see: https://github.com/electron/electron/issues/10016
|
||||
} 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.$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,141 @@
|
||||
<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/lensapp/lens/tree/master/src/features/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="upgrade" v-if="isUpgradeAvailable" :disabled="!cluster.isAdmin || isProcessing || isUpgrading" variant="primary">
|
||||
<b-spinner small v-if="isUpgrading" label="Small Spinner" />
|
||||
Upgrade
|
||||
</b-button>
|
||||
<b-button @click="uninstall" v-if="settings.installed" :disabled="!cluster.isAdmin || isProcessing || isUpgrading" variant="danger">
|
||||
<b-spinner small v-if="isProcessing" label="Small Spinner" />
|
||||
Uninstall
|
||||
</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() {
|
||||
return this.cluster.features.metrics.canUpgrade;
|
||||
},
|
||||
isProcessing: function() {
|
||||
return this.status === "PROCESSING";
|
||||
},
|
||||
isUpgrading: function() {
|
||||
return this.status === "UPGRADING";
|
||||
},
|
||||
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(){
|
||||
this.status = "UPGRADING";
|
||||
try {
|
||||
let result = await this.$store.dispatch("upgradeClusterFeature", {
|
||||
name: this.feature,
|
||||
clusterId: this.cluster.id,
|
||||
config: null,
|
||||
})
|
||||
this.$store.dispatch("refineCluster", this.cluster.id);
|
||||
this.status = "";
|
||||
this.errorMsg = "";
|
||||
} catch(error) {
|
||||
this.status = "ERROR"
|
||||
this.errorMsg = error.message
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
</style>
|
||||
@@ -0,0 +1,128 @@
|
||||
<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;
|
||||
|
||||
},
|
||||
}
|
||||
}
|
||||
</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,196 @@
|
||||
<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/lensapp/lens/blob/master/troubleshooting/custom-prometheus.md">guide</a> for possible configuration changes.</p>
|
||||
|
||||
<b-form-group
|
||||
label="Prometheus installation method."
|
||||
description="What query format is used to fetch metrics from Prometheus"
|
||||
>
|
||||
<b-form-select
|
||||
v-model="prometheusProvider"
|
||||
:options="prometheusProviders"
|
||||
@change="onPrometheusProviderSave"
|
||||
/>
|
||||
</b-form-group>
|
||||
<b-form-group
|
||||
label="Prometheus service address."
|
||||
description="An address to an existing Prometheus installation (<namespace>/<service>:<port>). Lens tries to auto-detect address if left empty."
|
||||
v-if="canEditPrometheusPath"
|
||||
>
|
||||
<b-form-input
|
||||
v-model="prometheusPath"
|
||||
placeholder="<namespace>/<service>:<port>"
|
||||
id="input-prometheuspath"
|
||||
@blur="onPrometheusSave"
|
||||
:state="errors.prometheusPath"
|
||||
/>
|
||||
</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"
|
||||
import { prometheusProviders } from '../../../../../common/prometheus-providers';
|
||||
|
||||
export default {
|
||||
name: 'ClusterSettingsPreferences',
|
||||
props: {
|
||||
cluster: {
|
||||
type: Object,
|
||||
default: null,
|
||||
}
|
||||
},
|
||||
data(){
|
||||
return {
|
||||
errors: {
|
||||
prometheusPath: null,
|
||||
terminalcwd: null
|
||||
},
|
||||
prometheusPath: "",
|
||||
prometheusProvider: "",
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
prometheusProviders: function() {
|
||||
const providers = prometheusProviders.map((provider) => {
|
||||
return { text: provider.name, value: provider.id }
|
||||
})
|
||||
providers.unshift({text: "Auto detect", value: ""})
|
||||
|
||||
return providers;
|
||||
},
|
||||
canEditPrometheusPath: function() {
|
||||
if (this.prometheusProvider === "") return false
|
||||
if (this.prometheusProvider === "lens") return false
|
||||
|
||||
return true
|
||||
}
|
||||
},
|
||||
mounted: async function() {
|
||||
this.updateValues()
|
||||
},
|
||||
methods: {
|
||||
updateValues: function(){
|
||||
if (this.cluster.preferences.prometheus) {
|
||||
const prom = this.cluster.preferences.prometheus
|
||||
if (!prom.prefix) prom.prefix = ""
|
||||
this.prometheusPath = `${prom.namespace}/${prom.service}:${prom.port}${prom.prefix}`
|
||||
} else {
|
||||
this.prometheusPath = ""
|
||||
}
|
||||
if (this.cluster.preferences.prometheusProvider) {
|
||||
this.prometheusProvider = this.cluster.preferences.prometheusProvider.type
|
||||
} else {
|
||||
this.prometheusProvider = ""
|
||||
}
|
||||
},
|
||||
parsePrometheusPath: function(path) {
|
||||
const parsed = path.split(/\/|:/, 3)
|
||||
const apiPrefix = path.substring(parsed.join("/").length)
|
||||
if (parsed[0] && parsed[1] && parsed[2]) {
|
||||
this.errors.prometheusPath = true;
|
||||
} else {
|
||||
this.errors.prometheusPath = false;
|
||||
}
|
||||
return {
|
||||
namespace: parsed[0],
|
||||
service: parsed[1],
|
||||
port: parsed[2],
|
||||
prefix: apiPrefix
|
||||
}
|
||||
},
|
||||
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);
|
||||
},
|
||||
onPrometheusProviderSave: function() {
|
||||
if (this.prometheusProvider === "") {
|
||||
this.cluster.preferences.prometheusProvider = null;
|
||||
this.cluster.preferences.prometheus = null;
|
||||
} else {
|
||||
this.cluster.preferences.prometheusProvider = { type: this.prometheusProvider }
|
||||
}
|
||||
this.$store.dispatch("storeCluster", this.cluster);
|
||||
},
|
||||
onTerminalCwdSave: function() {
|
||||
if(this.cluster.preferences.terminalCWD === "") this.cluster.preferences.terminalCWD = null
|
||||
this.$store.dispatch("storeCluster", this.cluster);
|
||||
},
|
||||
|
||||
},
|
||||
watch: {
|
||||
"cluster": "updateValues",
|
||||
}
|
||||
}
|
||||
</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 "@/_vue/components/CubeSpinner";
|
||||
import ClusterSettingsGeneral from "./General/index";
|
||||
import ClusterSettingsOverview from "./Overview/index";
|
||||
import ClusterSettingsPreferences from "./Preferences/index";
|
||||
import ClusterSettingsFeatures from "./Features/index";
|
||||
import hashicon from "../hashicon/hashicon";
|
||||
import ClosePageButton from "@/_vue/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>
|
||||
@@ -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-if="workspace.id !== 'default'" 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 "@/_vue/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_vue > .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,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 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 "@/_vue/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,108 @@
|
||||
<template>
|
||||
<div :style="{ paddingTop: computedPaddingTop }" 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 "@/_vue/components/MainMenu/ClusterMenuItem";
|
||||
import AddClusterMenuItem from "@/_vue/components/MainMenu/AddClusterMenuItem";
|
||||
import draggable from 'vuedraggable'
|
||||
import { clusterStore } from "../../../../common/cluster-store"
|
||||
import { isMac } from "../../../../common/vars"
|
||||
|
||||
const {remote} = require('electron')
|
||||
const {Menu, MenuItem} = remote
|
||||
|
||||
export default {
|
||||
name: "MainMenu",
|
||||
components: {
|
||||
ClusterMenuItem,
|
||||
AddClusterMenuItem,
|
||||
draggable
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
computedPaddingTop: "15px"
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
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 => {})
|
||||
}
|
||||
},
|
||||
mounted: function () {
|
||||
if (isMac) {
|
||||
this.computedPaddingTop = "25px";
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.main-menu {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 20px;
|
||||
width: 70px;
|
||||
padding-top: 15px;
|
||||
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>
|
||||
@@ -0,0 +1,257 @@
|
||||
<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>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>Download Mirror</h2>
|
||||
|
||||
<b-form-group
|
||||
label="Download mirror for kubectl:"
|
||||
label-for="input-download-mirror"
|
||||
>
|
||||
<b-form-select
|
||||
id="input-download-mirror"
|
||||
v-model="preferences.downloadMirror"
|
||||
@change="onSave"
|
||||
:options="downloadMirrorOptions"
|
||||
/>
|
||||
</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 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 request from 'request-promise-native'
|
||||
import { globalRequestOpts } from "../../../common/request"
|
||||
import ClosePageButton from "@/_vue/components/common/ClosePageButton";
|
||||
|
||||
export default {
|
||||
name: 'PreferencesPage',
|
||||
components: {
|
||||
ClosePageButton
|
||||
},
|
||||
data(){
|
||||
return {
|
||||
hubRepositories: [],
|
||||
selectedHubRepo: "",
|
||||
colorThemeOptions: [
|
||||
{ value: "dark", text: "Dark" },
|
||||
{ value: "light", text: "Light" },
|
||||
],
|
||||
downloadMirrorOptions: [
|
||||
{ value: "default", text: "Default (Google)" },
|
||||
{ value: "china", text: "China (Azure)" },
|
||||
],
|
||||
isHelmProcessing: null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
helmRepos: function() {
|
||||
return this.$store.getters.repos;
|
||||
},
|
||||
preferences: function() {
|
||||
return this.$store.getters.preferences;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onSave: function() {
|
||||
this.$store.commit("savePreferences", this.preferences);
|
||||
},
|
||||
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_vue > .main-view > .content {
|
||||
left: 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>
|
||||
@@ -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 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>
|
||||
@@ -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 "@/_vue/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_vue > .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(--lens-primary)
|
||||
}
|
||||
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: 25px;
|
||||
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 '@/_vue/components/hashicon/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>
|
||||
@@ -0,0 +1,49 @@
|
||||
import "./assets/css/app.scss"
|
||||
import "prismjs";
|
||||
import "prismjs/components/prism-yaml"
|
||||
import "prismjs/themes/prism-tomorrow.css";
|
||||
import "vue-prism-editor/dist/VuePrismEditor.css";
|
||||
|
||||
import { remote } from "electron"
|
||||
import Vue from 'vue'
|
||||
import VueElectron from 'vue-electron'
|
||||
import BootstrapVue from 'bootstrap-vue'
|
||||
import { PromiseIpc } from 'electron-promise-ipc'
|
||||
import { Tracker } from "../../common/tracker"
|
||||
import App from './App'
|
||||
import router from './router'
|
||||
import store from './store'
|
||||
|
||||
const tracker = new Tracker(remote.app);
|
||||
const promiseIpc = new PromiseIpc({maxTimeoutMs: 6000});
|
||||
|
||||
promiseIpc.on('navigate', async (view) => {
|
||||
router.push(view).catch(err => {})
|
||||
});
|
||||
|
||||
Vue.config.productionTip = false
|
||||
Vue.use(VueElectron)
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
Vue.mixin({
|
||||
created: function () {
|
||||
this.$promiseIpc = promiseIpc;
|
||||
this.$tracker = tracker;
|
||||
}
|
||||
})
|
||||
|
||||
export function appInitVue() {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await store.dispatch('init');
|
||||
new Vue({
|
||||
components: {App},
|
||||
store,
|
||||
router,
|
||||
template: '<App/>'
|
||||
}).$mount('#app_vue')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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));
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import Vue from 'vue'
|
||||
import Router from 'vue-router'
|
||||
import store from "../store";
|
||||
|
||||
// Route Guard
|
||||
import * as guard from './routeguard'
|
||||
|
||||
Vue.use(Router)
|
||||
|
||||
const router = new Router({
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'landing-page',
|
||||
component: require('@/_vue/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('@/_vue/components/PreferencesPage').default,
|
||||
},
|
||||
{
|
||||
path: '/workspaces',
|
||||
name: 'workspaces-page',
|
||||
component: require('@/_vue/components/WorkspacesPage').default,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/add-workspace',
|
||||
name: 'add-workspace-page',
|
||||
component: require('@/_vue/components/AddWorkspacePage').default,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/edit-workspace',
|
||||
name: 'edit-workspace-page',
|
||||
component: require('@/_vue/components/EditWorkspacePage').default,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/clusters/:id',
|
||||
name: 'cluster-page',
|
||||
component: require('@/_vue/components/ClusterPage').default,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/clusters/:id/settings',
|
||||
name: 'cluster-settings-page',
|
||||
component: require('@/_vue/components/ClusterSettings').default,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: "/add-cluster",
|
||||
name: "add-cluster-page",
|
||||
component: require('@/_vue/components/AddClusterPage').default,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: "/whats-new",
|
||||
name: "whats-new-page",
|
||||
component: require('@/_vue/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;
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import Vue from 'vue'
|
||||
import Vuex from 'vuex'
|
||||
import semver from "semver"
|
||||
import { userStore } from "../../../common/user-store"
|
||||
import { getAppVersion } from "../../../common/utils/app-version"
|
||||
import KubeContexts from './modules/kube-contexts'
|
||||
import Clusters from './modules/clusters'
|
||||
import HelmRepos from './modules/helm-repos'
|
||||
import Workspaces from './modules/workspaces'
|
||||
|
||||
// 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", 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(getAppVersion(), state.lastSeenAppVersion);
|
||||
},
|
||||
preferences: state => state.preferences,
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
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 upgradeClusterFeature({commit}, data) {
|
||||
// Custom no timeout IPC as install can take very variable time
|
||||
const ipc = new PromiseIpc();
|
||||
const response = await ipc.send('upgradeFeature', data)
|
||||
console.log("upgrade result:", response);
|
||||
const cluster = await ipc.send('refreshCluster', data.clusterId)
|
||||
|
||||
|
||||
tracker.event("cluster", "upgrade-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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from "react";
|
||||
import { observable } from "mobx";
|
||||
import { autobind } from "../utils/autobind";
|
||||
import { KubeApi } from "./kube-api";
|
||||
import { KubeObjectStore } from "../kube-object.store";
|
||||
import { KubeObjectDetailsProps, KubeObjectListLayoutProps, KubeObjectMenuProps } from "../components/kube-object";
|
||||
|
||||
export interface ApiComponents {
|
||||
List?: React.ComponentType<KubeObjectListLayoutProps>;
|
||||
Menu?: React.ComponentType<KubeObjectMenuProps>;
|
||||
Details?: React.ComponentType<KubeObjectDetailsProps>;
|
||||
}
|
||||
|
||||
@autobind()
|
||||
export class ApiManager {
|
||||
private apis = observable.map<string, KubeApi>();
|
||||
private stores = observable.map<KubeApi, KubeObjectStore>();
|
||||
private views = observable.map<KubeApi, ApiComponents>();
|
||||
|
||||
getApi(pathOrCallback: string | ((api: KubeApi) => boolean)) {
|
||||
const apis = this.apis;
|
||||
if (typeof pathOrCallback === "string") {
|
||||
let api = apis.get(pathOrCallback);
|
||||
if (!api) {
|
||||
const { apiBase } = KubeApi.parseApi(pathOrCallback);
|
||||
api = apis.get(apiBase);
|
||||
}
|
||||
return api;
|
||||
}
|
||||
else {
|
||||
return Array.from(apis.values()).find(pathOrCallback);
|
||||
}
|
||||
}
|
||||
|
||||
registerApi(apiBase: string, api: KubeApi) {
|
||||
if (this.apis.has(apiBase)) return;
|
||||
this.apis.set(apiBase, api);
|
||||
}
|
||||
|
||||
protected resolveApi(api: string | KubeApi): KubeApi {
|
||||
if (typeof api === "string") return this.getApi(api)
|
||||
return api;
|
||||
}
|
||||
|
||||
unregisterApi(api: string | KubeApi) {
|
||||
if (typeof api === "string") this.apis.delete(api);
|
||||
else {
|
||||
const apis = Array.from(this.apis.entries());
|
||||
const entry = apis.find(entry => entry[1] === api);
|
||||
if (entry) this.unregisterApi(entry[0]);
|
||||
}
|
||||
}
|
||||
|
||||
registerStore(api: KubeApi, store: KubeObjectStore) {
|
||||
this.stores.set(api, store);
|
||||
}
|
||||
|
||||
getStore(api: string | KubeApi): KubeObjectStore {
|
||||
return this.stores.get(this.resolveApi(api));
|
||||
}
|
||||
|
||||
registerViews(api: KubeApi | KubeApi[], views: ApiComponents) {
|
||||
if (Array.isArray(api)) {
|
||||
api.forEach(api => this.registerViews(api, views));
|
||||
return;
|
||||
}
|
||||
const currentViews = this.views.get(api) || {};
|
||||
this.views.set(api, {
|
||||
...currentViews,
|
||||
...views,
|
||||
});
|
||||
}
|
||||
|
||||
getViews(api: string | KubeApi): ApiComponents {
|
||||
return this.views.get(this.resolveApi(api)) || {}
|
||||
}
|
||||
}
|
||||
|
||||
export const apiManager = new ApiManager();
|
||||
@@ -0,0 +1,261 @@
|
||||
// Kubernetes certificate management controller apis
|
||||
// Reference: https://docs.cert-manager.io/en/latest/reference/index.html
|
||||
// API docs: https://docs.cert-manager.io/en/latest/reference/api-docs/index.html
|
||||
|
||||
import { KubeObject } from "../kube-object";
|
||||
import { ISecretRef, secretsApi } from "./secret.api";
|
||||
import { getDetailsUrl } from "../../navigation";
|
||||
import { KubeApi } from "../kube-api";
|
||||
|
||||
export class Certificate extends KubeObject {
|
||||
static kind = "Certificate"
|
||||
|
||||
spec: {
|
||||
secretName: string;
|
||||
commonName?: string;
|
||||
dnsNames?: string[];
|
||||
organization?: string[];
|
||||
ipAddresses?: string[];
|
||||
duration?: string;
|
||||
renewBefore?: string;
|
||||
isCA?: boolean;
|
||||
keySize?: number;
|
||||
keyAlgorithm?: "rsa" | "ecdsa";
|
||||
issuerRef: {
|
||||
kind?: string;
|
||||
name: string;
|
||||
};
|
||||
acme?: {
|
||||
config: {
|
||||
domains: string[];
|
||||
http01: {
|
||||
ingress?: string;
|
||||
ingressClass?: string;
|
||||
};
|
||||
dns01?: {
|
||||
provider: string;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
}
|
||||
status: {
|
||||
conditions?: {
|
||||
lastTransitionTime: string; // 2019-06-04T07:35:58Z,
|
||||
message: string; // Certificate is up to date and has not expired,
|
||||
reason: string; // Ready,
|
||||
status: string; // True,
|
||||
type: string; // Ready
|
||||
}[];
|
||||
notAfter: string; // 2019-11-01T05:36:27Z
|
||||
lastFailureTime?: string;
|
||||
}
|
||||
|
||||
getType(): string {
|
||||
const { isCA, acme } = this.spec;
|
||||
if (isCA) return "CA"
|
||||
if (acme) return "ACME"
|
||||
}
|
||||
|
||||
getCommonName() {
|
||||
return this.spec.commonName || ""
|
||||
}
|
||||
|
||||
getIssuerName() {
|
||||
return this.spec.issuerRef.name;
|
||||
}
|
||||
|
||||
getSecretName() {
|
||||
return this.spec.secretName;
|
||||
}
|
||||
|
||||
getIssuerDetailsUrl() {
|
||||
return getDetailsUrl(issuersApi.getUrl({
|
||||
namespace: this.getNs(),
|
||||
name: this.getIssuerName(),
|
||||
}))
|
||||
}
|
||||
|
||||
getSecretDetailsUrl() {
|
||||
return getDetailsUrl(secretsApi.getUrl({
|
||||
namespace: this.getNs(),
|
||||
name: this.getSecretName(),
|
||||
}))
|
||||
}
|
||||
|
||||
getConditions() {
|
||||
const { conditions = [] } = this.status;
|
||||
return conditions.map(condition => {
|
||||
const { message, reason, lastTransitionTime, status } = condition;
|
||||
return {
|
||||
...condition,
|
||||
isReady: status === "True",
|
||||
tooltip: `${message || reason} (${lastTransitionTime})`
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class Issuer extends KubeObject {
|
||||
static kind = "Issuer"
|
||||
|
||||
spec: {
|
||||
acme?: {
|
||||
email: string;
|
||||
server: string;
|
||||
skipTLSVerify?: boolean;
|
||||
privateKeySecretRef: ISecretRef;
|
||||
solvers?: {
|
||||
dns01?: {
|
||||
cnameStrategy: string;
|
||||
acmedns?: {
|
||||
host: string;
|
||||
accountSecretRef: ISecretRef;
|
||||
};
|
||||
akamai?: {
|
||||
accessTokenSecretRef: ISecretRef;
|
||||
clientSecretSecretRef: ISecretRef;
|
||||
clientTokenSecretRef: ISecretRef;
|
||||
serviceConsumerDomain: string;
|
||||
};
|
||||
azuredns?: {
|
||||
clientID: string;
|
||||
clientSecretSecretRef: ISecretRef;
|
||||
hostedZoneName: string;
|
||||
resourceGroupName: string;
|
||||
subscriptionID: string;
|
||||
tenantID: string;
|
||||
};
|
||||
clouddns?: {
|
||||
project: string;
|
||||
serviceAccountSecretRef: ISecretRef;
|
||||
};
|
||||
cloudflare?: {
|
||||
email: string;
|
||||
apiKeySecretRef: ISecretRef;
|
||||
};
|
||||
digitalocean?: {
|
||||
tokenSecretRef: ISecretRef;
|
||||
};
|
||||
rfc2136?: {
|
||||
nameserver: string;
|
||||
tsigAlgorithm: string;
|
||||
tsigKeyName: string;
|
||||
tsigSecretSecretRef: ISecretRef;
|
||||
};
|
||||
route53?: {
|
||||
accessKeyID: string;
|
||||
hostedZoneID: string;
|
||||
region: string;
|
||||
secretAccessKeySecretRef: ISecretRef;
|
||||
};
|
||||
webhook?: {
|
||||
config: object; // arbitrary json
|
||||
groupName: string;
|
||||
solverName: string;
|
||||
};
|
||||
};
|
||||
http01?: {
|
||||
ingress: {
|
||||
class: string;
|
||||
name: string;
|
||||
serviceType: string;
|
||||
};
|
||||
};
|
||||
selector?: {
|
||||
dnsNames: string[];
|
||||
matchLabels: {
|
||||
[label: string]: string;
|
||||
};
|
||||
};
|
||||
}[];
|
||||
};
|
||||
ca?: {
|
||||
secretName: string;
|
||||
};
|
||||
vault?: {
|
||||
path: string;
|
||||
server: string;
|
||||
caBundle: string; // <base64 encoded caBundle PEM file>
|
||||
auth: {
|
||||
appRole: {
|
||||
path: string;
|
||||
roleId: string;
|
||||
secretRef: ISecretRef;
|
||||
};
|
||||
};
|
||||
};
|
||||
selfSigned?: {};
|
||||
venafi?: {
|
||||
zone: string;
|
||||
cloud?: {
|
||||
apiTokenSecretRef: ISecretRef;
|
||||
};
|
||||
tpp?: {
|
||||
url: string;
|
||||
caBundle: string; // <base64 encoded caBundle PEM file>
|
||||
credentialsRef: {
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
status: {
|
||||
acme?: {
|
||||
uri: string;
|
||||
};
|
||||
conditions?: {
|
||||
lastTransitionTime: string; // 2019-06-05T07:10:42Z,
|
||||
message: string; // The ACME account was registered with the ACME server,
|
||||
reason: string; // ACMEAccountRegistered,
|
||||
status: string; // True,
|
||||
type: string; // Ready
|
||||
}[];
|
||||
}
|
||||
|
||||
getType() {
|
||||
const { acme, ca, selfSigned, vault, venafi } = this.spec;
|
||||
if (acme) return "ACME"
|
||||
if (ca) return "CA"
|
||||
if (selfSigned) return "SelfSigned"
|
||||
if (vault) return "Vault"
|
||||
if (venafi) return "Venafi"
|
||||
}
|
||||
|
||||
getConditions() {
|
||||
const { conditions = [] } = this.status;
|
||||
return conditions.map(condition => {
|
||||
const { message, reason, lastTransitionTime, status } = condition;
|
||||
return {
|
||||
...condition,
|
||||
isReady: status === "True",
|
||||
tooltip: `${message || reason} (${lastTransitionTime})`,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterIssuer extends Issuer {
|
||||
static kind = "ClusterIssuer"
|
||||
}
|
||||
|
||||
export const certificatesApi = new KubeApi({
|
||||
kind: Certificate.kind,
|
||||
apiBase: "/apis/cert-manager.io/v1alpha2/certificates",
|
||||
isNamespaced: true,
|
||||
objectConstructor: Certificate,
|
||||
});
|
||||
|
||||
export const issuersApi = new KubeApi({
|
||||
kind: Issuer.kind,
|
||||
apiBase: "/apis/cert-manager.io/v1alpha2/issuers",
|
||||
isNamespaced: true,
|
||||
objectConstructor: Issuer,
|
||||
});
|
||||
|
||||
export const clusterIssuersApi = new KubeApi({
|
||||
kind: ClusterIssuer.kind,
|
||||
apiBase: "/apis/cert-manager.io/v1alpha2/clusterissuers",
|
||||
isNamespaced: false,
|
||||
objectConstructor: ClusterIssuer,
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { RoleBinding } from "./role-binding.api";
|
||||
import { KubeApi } from "../kube-api";
|
||||
|
||||
export class ClusterRoleBinding extends RoleBinding {
|
||||
static kind = "ClusterRoleBinding"
|
||||
}
|
||||
|
||||
export const clusterRoleBindingApi = new KubeApi({
|
||||
kind: ClusterRoleBinding.kind,
|
||||
apiBase: "/apis/rbac.authorization.k8s.io/v1/clusterrolebindings",
|
||||
isNamespaced: false,
|
||||
objectConstructor: ClusterRoleBinding,
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { autobind } from "../../utils";
|
||||
import { Role } from "./role.api";
|
||||
import { KubeApi } from "../kube-api";
|
||||
|
||||
@autobind()
|
||||
export class ClusterRole extends Role {
|
||||
static kind = "ClusterRole"
|
||||
}
|
||||
|
||||
export const clusterRoleApi = new KubeApi({
|
||||
kind: ClusterRole.kind,
|
||||
apiBase: "/apis/rbac.authorization.k8s.io/v1/clusterroles",
|
||||
isNamespaced: false,
|
||||
objectConstructor: ClusterRole,
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { IMetrics, IMetricsReqParams, metricsApi } from "./metrics.api";
|
||||
import { KubeObject } from "../kube-object";
|
||||
import { KubeApi } from "../kube-api";
|
||||
|
||||
export class ClusterApi extends KubeApi<Cluster> {
|
||||
async getMetrics(nodeNames: string[], params?: IMetricsReqParams): Promise<IClusterMetrics> {
|
||||
const nodes = nodeNames.join("|");
|
||||
const opts = { category: "cluster", nodes: nodes }
|
||||
|
||||
return metricsApi.getMetrics({
|
||||
memoryUsage: opts,
|
||||
memoryRequests: opts,
|
||||
memoryLimits: opts,
|
||||
memoryCapacity: opts,
|
||||
cpuUsage: opts,
|
||||
cpuRequests: opts,
|
||||
cpuLimits: opts,
|
||||
cpuCapacity: opts,
|
||||
podUsage: opts,
|
||||
podCapacity: opts,
|
||||
fsSize: opts,
|
||||
fsUsage: opts
|
||||
}, params);
|
||||
}
|
||||
}
|
||||
|
||||
export enum ClusterStatus {
|
||||
ACTIVE = "Active",
|
||||
CREATING = "Creating",
|
||||
REMOVING = "Removing",
|
||||
ERROR = "Error"
|
||||
}
|
||||
|
||||
export interface IClusterMetrics<T = IMetrics> {
|
||||
[metric: string]: T;
|
||||
memoryUsage: T;
|
||||
memoryRequests: T;
|
||||
memoryLimits: T;
|
||||
memoryCapacity: T;
|
||||
cpuUsage: T;
|
||||
cpuRequests: T;
|
||||
cpuLimits: T;
|
||||
cpuCapacity: T;
|
||||
podUsage: T;
|
||||
podCapacity: T;
|
||||
fsSize: T;
|
||||
fsUsage: T;
|
||||
}
|
||||
|
||||
export class Cluster extends KubeObject {
|
||||
static kind = "Cluster";
|
||||
|
||||
spec: {
|
||||
clusterNetwork?: {
|
||||
serviceDomain?: string;
|
||||
pods?: {
|
||||
cidrBlocks?: string[];
|
||||
};
|
||||
services?: {
|
||||
cidrBlocks?: string[];
|
||||
};
|
||||
};
|
||||
providerSpec: {
|
||||
value: {
|
||||
profile: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
status?: {
|
||||
apiEndpoints: {
|
||||
host: string;
|
||||
port: string;
|
||||
}[];
|
||||
providerStatus: {
|
||||
adminUser?: string;
|
||||
adminPassword?: string;
|
||||
kubeconfig?: string;
|
||||
processState?: string;
|
||||
lensAddress?: string;
|
||||
};
|
||||
errorMessage?: string;
|
||||
errorReason?: string;
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
if (this.metadata.deletionTimestamp) return ClusterStatus.REMOVING;
|
||||
if (!this.status || !this.status) return ClusterStatus.CREATING;
|
||||
if (this.status.errorMessage) return ClusterStatus.ERROR;
|
||||
return ClusterStatus.ACTIVE;
|
||||
}
|
||||
}
|
||||
|
||||
export const clusterApi = new ClusterApi({
|
||||
kind: Cluster.kind,
|
||||
apiBase: "/apis/cluster.k8s.io/v1alpha1/clusters",
|
||||
isNamespaced: true,
|
||||
objectConstructor: Cluster,
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { KubeObject } from "../kube-object";
|
||||
import { KubeApi } from "../kube-api";
|
||||
|
||||
export interface IComponentStatusCondition {
|
||||
type: string;
|
||||
status: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export class ComponentStatus extends KubeObject {
|
||||
static kind = "ComponentStatus"
|
||||
|
||||
conditions: IComponentStatusCondition[]
|
||||
|
||||
getTruthyConditions() {
|
||||
return this.conditions.filter(c => c.status === "True");
|
||||
}
|
||||
}
|
||||
|
||||
export const componentStatusApi = new KubeApi({
|
||||
kind: ComponentStatus.kind,
|
||||
apiBase: "/api/v1/componentstatuses",
|
||||
isNamespaced: false,
|
||||
objectConstructor: ComponentStatus,
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
// App configuration api
|
||||
import type { IConfigRoutePayload } from "../../../main/routes/config";
|
||||
import { apiBase } from "../index";
|
||||
|
||||
export const configApi = {
|
||||
getConfig() {
|
||||
return apiBase.get<IConfigRoutePayload>("/config")
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { KubeObject } from "../kube-object";
|
||||
import { KubeJsonApiData } from "../kube-json-api";
|
||||
import { autobind } from "../../utils";
|
||||
import { KubeApi } from "../kube-api";
|
||||
|
||||
@autobind()
|
||||
export class ConfigMap extends KubeObject {
|
||||
static kind = "ConfigMap";
|
||||
|
||||
constructor(data: KubeJsonApiData) {
|
||||
super(data);
|
||||
this.data = this.data || {};
|
||||
}
|
||||
|
||||
data: {
|
||||
[param: string]: string;
|
||||
}
|
||||
|
||||
getKeys(): string[] {
|
||||
return Object.keys(this.data);
|
||||
}
|
||||
}
|
||||
|
||||
export const configMapApi = new KubeApi({
|
||||
kind: ConfigMap.kind,
|
||||
apiBase: "/api/v1/configmaps",
|
||||
isNamespaced: true,
|
||||
objectConstructor: ConfigMap,
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { KubeObject } from "../kube-object";
|
||||
import { KubeApi } from "../kube-api";
|
||||
import { crdResourcesURL } from "../../components/+custom-resources/crd.route";
|
||||
|
||||
export class CustomResourceDefinition extends KubeObject {
|
||||
static kind = "CustomResourceDefinition";
|
||||
|
||||
spec: {
|
||||
group: string;
|
||||
version: string;
|
||||
names: {
|
||||
plural: string;
|
||||
singular: string;
|
||||
kind: string;
|
||||
listKind: string;
|
||||
};
|
||||
scope: "Namespaced" | "Cluster" | string;
|
||||
validation?: any;
|
||||
versions: {
|
||||
name: string;
|
||||
served: boolean;
|
||||
storage: boolean;
|
||||
}[];
|
||||
conversion: {
|
||||
strategy?: string;
|
||||
webhook?: any;
|
||||
};
|
||||
additionalPrinterColumns?: {
|
||||
name: string;
|
||||
type: "integer" | "number" | "string" | "boolean" | "date";
|
||||
priority: number;
|
||||
description: string;
|
||||
JSONPath: string;
|
||||
}[];
|
||||
}
|
||||
status: {
|
||||
conditions: {
|
||||
lastTransitionTime: string;
|
||||
message: string;
|
||||
reason: string;
|
||||
status: string;
|
||||
type: string;
|
||||
}[];
|
||||
acceptedNames: {
|
||||
plural: string;
|
||||
singular: string;
|
||||
kind: string;
|
||||
shortNames: string[];
|
||||
listKind: string;
|
||||
};
|
||||
storedVersions: string[];
|
||||
}
|
||||
|
||||
getResourceUrl() {
|
||||
return crdResourcesURL({
|
||||
params: {
|
||||
group: this.getGroup(),
|
||||
name: this.getPluralName(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
getResourceApiBase() {
|
||||
const { version, group } = this.spec;
|
||||
return `/apis/${group}/${version}/${this.getPluralName()}`
|
||||
}
|
||||
|
||||
getPluralName() {
|
||||
return this.getNames().plural
|
||||
}
|
||||
|
||||
getResourceKind() {
|
||||
return this.spec.names.kind
|
||||
}
|
||||
|
||||
getResourceTitle() {
|
||||
const name = this.getPluralName();
|
||||
return name[0].toUpperCase() + name.substr(1)
|
||||
}
|
||||
|
||||
getGroup() {
|
||||
return this.spec.group;
|
||||
}
|
||||
|
||||
getScope() {
|
||||
return this.spec.scope;
|
||||
}
|
||||
|
||||
getVersion() {
|
||||
return this.spec.version;
|
||||
}
|
||||
|
||||
isNamespaced() {
|
||||
return this.getScope() === "Namespaced";
|
||||
}
|
||||
|
||||
getStoredVersions() {
|
||||
return this.status.storedVersions.join(", ");
|
||||
}
|
||||
|
||||
getNames() {
|
||||
return this.spec.names;
|
||||
}
|
||||
|
||||
getConversion() {
|
||||
return JSON.stringify(this.spec.conversion);
|
||||
}
|
||||
|
||||
getPrinterColumns(ignorePriority = true) {
|
||||
const columns = this.spec.additionalPrinterColumns || [];
|
||||
return columns
|
||||
.filter(column => column.name != "Age")
|
||||
.filter(column => ignorePriority ? true : !column.priority);
|
||||
}
|
||||
|
||||
getValidation() {
|
||||
return JSON.stringify(this.spec.validation, null, 2);
|
||||
}
|
||||
|
||||
getConditions() {
|
||||
if (!this.status.conditions) return [];
|
||||
return this.status.conditions.map(condition => {
|
||||
const { message, reason, lastTransitionTime, status } = condition;
|
||||
return {
|
||||
...condition,
|
||||
isReady: status === "True",
|
||||
tooltip: `${message || reason} (${lastTransitionTime})`
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const crdApi = new KubeApi<CustomResourceDefinition>({
|
||||
kind: CustomResourceDefinition.kind,
|
||||
apiBase: "/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions",
|
||||
isNamespaced: false,
|
||||
objectConstructor: CustomResourceDefinition,
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import moment from "moment";
|
||||
import { KubeObject } from "../kube-object";
|
||||
import { IPodContainer } from "./pods.api";
|
||||
import { formatDuration } from "../../utils/formatDuration";
|
||||
import { autobind } from "../../utils";
|
||||
import { KubeApi } from "../kube-api";
|
||||
|
||||
@autobind()
|
||||
export class CronJob extends KubeObject {
|
||||
static kind = "CronJob"
|
||||
|
||||
kind: string
|
||||
apiVersion: string
|
||||
metadata: {
|
||||
name: string;
|
||||
namespace: string;
|
||||
selfLink: string;
|
||||
uid: string;
|
||||
resourceVersion: string;
|
||||
creationTimestamp: string;
|
||||
labels: {
|
||||
[key: string]: string;
|
||||
};
|
||||
annotations: {
|
||||
[key: string]: string;
|
||||
};
|
||||
}
|
||||
spec: {
|
||||
schedule: string;
|
||||
concurrencyPolicy: string;
|
||||
suspend: boolean;
|
||||
jobTemplate: {
|
||||
metadata: {
|
||||
creationTimestamp?: string;
|
||||
};
|
||||
spec: {
|
||||
template: {
|
||||
metadata: {
|
||||
creationTimestamp?: string;
|
||||
};
|
||||
spec: {
|
||||
containers: IPodContainer[];
|
||||
restartPolicy: string;
|
||||
terminationGracePeriodSeconds: number;
|
||||
dnsPolicy: string;
|
||||
hostPID: boolean;
|
||||
schedulerName: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
successfulJobsHistoryLimit: number;
|
||||
failedJobsHistoryLimit: number;
|
||||
}
|
||||
status: {
|
||||
lastScheduleTime: string;
|
||||
}
|
||||
|
||||
getSuspendFlag() {
|
||||
return this.spec.suspend.toString()
|
||||
}
|
||||
|
||||
getLastScheduleTime() {
|
||||
const diff = moment().diff(this.status.lastScheduleTime)
|
||||
return formatDuration(diff, true)
|
||||
}
|
||||
|
||||
getSchedule() {
|
||||
return this.spec.schedule
|
||||
}
|
||||
|
||||
isNeverRun() {
|
||||
const schedule = this.getSchedule();
|
||||
const daysInMonth = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
const stamps = schedule.split(" ");
|
||||
const day = Number(stamps[stamps.length - 3]); // 1-31
|
||||
const month = Number(stamps[stamps.length - 2]); // 1-12
|
||||
if (schedule.startsWith("@")) return false;
|
||||
return day > daysInMonth[month - 1];
|
||||
}
|
||||
}
|
||||
|
||||
export const cronJobApi = new KubeApi({
|
||||
kind: CronJob.kind,
|
||||
apiBase: "/apis/batch/v1beta1/cronjobs",
|
||||
isNamespaced: true,
|
||||
objectConstructor: CronJob,
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import get from "lodash/get";
|
||||
import { IPodContainer } from "./pods.api";
|
||||
import { IAffinity, WorkloadKubeObject } from "../workload-kube-object";
|
||||
import { autobind } from "../../utils";
|
||||
import { KubeApi } from "../kube-api";
|
||||
|
||||
@autobind()
|
||||
export class DaemonSet extends WorkloadKubeObject {
|
||||
static kind = "DaemonSet"
|
||||
|
||||
spec: {
|
||||
selector: {
|
||||
matchLabels: {
|
||||
[name: string]: string;
|
||||
};
|
||||
};
|
||||
template: {
|
||||
metadata: {
|
||||
creationTimestamp?: string;
|
||||
labels: {
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
spec: {
|
||||
containers: IPodContainer[];
|
||||
initContainers?: IPodContainer[];
|
||||
restartPolicy: string;
|
||||
terminationGracePeriodSeconds: number;
|
||||
dnsPolicy: string;
|
||||
hostPID: boolean;
|
||||
affinity?: IAffinity;
|
||||
nodeSelector?: {
|
||||
[selector: string]: string;
|
||||
};
|
||||
securityContext: {};
|
||||
schedulerName: string;
|
||||
tolerations: {
|
||||
key: string;
|
||||
operator: string;
|
||||
effect: string;
|
||||
tolerationSeconds: number;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
updateStrategy: {
|
||||
type: string;
|
||||
rollingUpdate: {
|
||||
maxUnavailable: number;
|
||||
};
|
||||
};
|
||||
revisionHistoryLimit: number;
|
||||
}
|
||||
status: {
|
||||
currentNumberScheduled: number;
|
||||
numberMisscheduled: number;
|
||||
desiredNumberScheduled: number;
|
||||
numberReady: number;
|
||||
observedGeneration: number;
|
||||
updatedNumberScheduled: number;
|
||||
numberAvailable: number;
|
||||
numberUnavailable: number;
|
||||
}
|
||||
|
||||
getImages() {
|
||||
const containers: IPodContainer[] = get(this, "spec.template.spec.containers", [])
|
||||
const initContainers: IPodContainer[] = get(this, "spec.template.spec.initContainers", [])
|
||||
return [...containers, ...initContainers].map(container => container.image)
|
||||
}
|
||||
}
|
||||
|
||||
export const daemonSetApi = new KubeApi({
|
||||
kind: DaemonSet.kind,
|
||||
apiBase: "/apis/apps/v1/daemonsets",
|
||||
isNamespaced: true,
|
||||
objectConstructor: DaemonSet,
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { IAffinity, WorkloadKubeObject } from "../workload-kube-object";
|
||||
import { autobind } from "../../utils";
|
||||
import { KubeApi } from "../kube-api";
|
||||
|
||||
export class DeploymentApi extends KubeApi<Deployment> {
|
||||
protected getScaleApiUrl(params: { namespace: string; name: string }) {
|
||||
return this.getUrl(params) + "/scale"
|
||||
}
|
||||
|
||||
getReplicas(params: { namespace: string; name: string }): Promise<number> {
|
||||
return this.request
|
||||
.get(this.getScaleApiUrl(params))
|
||||
.then(({ status }: any) => status.replicas)
|
||||
}
|
||||
|
||||
scale(params: { namespace: string; name: string }, replicas: number) {
|
||||
return this.request.put(this.getScaleApiUrl(params), {
|
||||
data: {
|
||||
metadata: params,
|
||||
spec: {
|
||||
replicas: replicas
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@autobind()
|
||||
export class Deployment extends WorkloadKubeObject {
|
||||
static kind = "Deployment"
|
||||
|
||||
spec: {
|
||||
replicas: number;
|
||||
selector: { matchLabels: { [app: string]: string } };
|
||||
template: {
|
||||
metadata: {
|
||||
creationTimestamp?: string;
|
||||
labels: { [app: string]: string };
|
||||
};
|
||||
spec: {
|
||||
containers: {
|
||||
name: string;
|
||||
image: string;
|
||||
args?: string[];
|
||||
ports?: {
|
||||
name: string;
|
||||
containerPort: number;
|
||||
protocol: string;
|
||||
}[];
|
||||
env?: {
|
||||
name: string;
|
||||
value: string;
|
||||
}[];
|
||||
resources: {
|
||||
limits?: {
|
||||
cpu: string;
|
||||
memory: string;
|
||||
};
|
||||
requests: {
|
||||
cpu: string;
|
||||
memory: string;
|
||||
};
|
||||
};
|
||||
volumeMounts?: {
|
||||
name: string;
|
||||
mountPath: string;
|
||||
}[];
|
||||
livenessProbe?: {
|
||||
httpGet: {
|
||||
path: string;
|
||||
port: number;
|
||||
scheme: string;
|
||||
};
|
||||
initialDelaySeconds: number;
|
||||
timeoutSeconds: number;
|
||||
periodSeconds: number;
|
||||
successThreshold: number;
|
||||
failureThreshold: number;
|
||||
};
|
||||
readinessProbe?: {
|
||||
httpGet: {
|
||||
path: string;
|
||||
port: number;
|
||||
scheme: string;
|
||||
};
|
||||
initialDelaySeconds: number;
|
||||
timeoutSeconds: number;
|
||||
periodSeconds: number;
|
||||
successThreshold: number;
|
||||
failureThreshold: number;
|
||||
};
|
||||
terminationMessagePath: string;
|
||||
terminationMessagePolicy: string;
|
||||
imagePullPolicy: string;
|
||||
}[];
|
||||
restartPolicy: string;
|
||||
terminationGracePeriodSeconds: number;
|
||||
dnsPolicy: string;
|
||||
affinity?: IAffinity;
|
||||
nodeSelector?: {
|
||||
[selector: string]: string;
|
||||
};
|
||||
serviceAccountName: string;
|
||||
serviceAccount: string;
|
||||
securityContext: {};
|
||||
schedulerName: string;
|
||||
tolerations?: {
|
||||
key: string;
|
||||
operator: string;
|
||||
effect: string;
|
||||
tolerationSeconds: number;
|
||||
}[];
|
||||
volumes?: {
|
||||
name: string;
|
||||
configMap: {
|
||||
name: string;
|
||||
defaultMode: number;
|
||||
optional: boolean;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
};
|
||||
strategy: {
|
||||
type: string;
|
||||
rollingUpdate: {
|
||||
maxUnavailable: number;
|
||||
maxSurge: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
status: {
|
||||
observedGeneration: number;
|
||||
replicas: number;
|
||||
updatedReplicas: number;
|
||||
readyReplicas: number;
|
||||
availableReplicas?: number;
|
||||
unavailableReplicas?: number;
|
||||
conditions: {
|
||||
type: string;
|
||||
status: string;
|
||||
lastUpdateTime: string;
|
||||
lastTransitionTime: string;
|
||||
reason: string;
|
||||
message: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
getConditions(activeOnly = false) {
|
||||
const { conditions } = this.status
|
||||
if (!conditions) return []
|
||||
if (activeOnly) {
|
||||
return conditions.filter(c => c.status === "True")
|
||||
}
|
||||
return conditions
|
||||
}
|
||||
|
||||
getConditionsText(activeOnly = true) {
|
||||
return this.getConditions(activeOnly).map(({ type }) => type).join(" ")
|
||||
}
|
||||
|
||||
getReplicas() {
|
||||
return this.spec.replicas || 0;
|
||||
}
|
||||
}
|
||||
|
||||
export const deploymentApi = new DeploymentApi({
|
||||
kind: Deployment.kind,
|
||||
apiBase: "/apis/apps/v1/deployments",
|
||||
isNamespaced: true,
|
||||
objectConstructor: Deployment,
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { autobind } from "../../utils";
|
||||
import { KubeObject } from "../kube-object";
|
||||
import { KubeApi } from "../kube-api";
|
||||
|
||||
export interface IEndpointPort {
|
||||
name?: string;
|
||||
protocol: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export interface IEndpointAddress {
|
||||
hostname: string;
|
||||
ip: string;
|
||||
nodeName: string;
|
||||
}
|
||||
|
||||
export interface IEndpointSubset {
|
||||
addresses: IEndpointAddress[];
|
||||
notReadyAddresses: IEndpointAddress[];
|
||||
ports: IEndpointPort[];
|
||||
}
|
||||
|
||||
interface ITargetRef {
|
||||
kind: string;
|
||||
namespace: string;
|
||||
name: string;
|
||||
uid: string;
|
||||
resourceVersion: string;
|
||||
apiVersion: string;
|
||||
}
|
||||
|
||||
export class EndpointAddress implements IEndpointAddress {
|
||||
hostname: string;
|
||||
ip: string;
|
||||
nodeName: string;
|
||||
targetRef?: {
|
||||
kind: string;
|
||||
namespace: string;
|
||||
name: string;
|
||||
uid: string;
|
||||
resourceVersion: string;
|
||||
};
|
||||
|
||||
constructor(data: IEndpointAddress) {
|
||||
Object.assign(this, data)
|
||||
}
|
||||
|
||||
getId() {
|
||||
return this.ip
|
||||
}
|
||||
|
||||
getName() {
|
||||
return this.hostname
|
||||
}
|
||||
|
||||
getTargetRef(): ITargetRef {
|
||||
if (this.targetRef) {
|
||||
return Object.assign(this.targetRef, {apiVersion: "v1"})
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class EndpointSubset implements IEndpointSubset {
|
||||
addresses: IEndpointAddress[];
|
||||
notReadyAddresses: IEndpointAddress[];
|
||||
ports: IEndpointPort[];
|
||||
|
||||
constructor(data: IEndpointSubset) {
|
||||
Object.assign(this, data)
|
||||
}
|
||||
|
||||
getAddresses(): EndpointAddress[] {
|
||||
const addresses = this.addresses || [];
|
||||
return addresses.map(a => new EndpointAddress(a));
|
||||
}
|
||||
|
||||
getNotReadyAddresses(): EndpointAddress[] {
|
||||
const notReadyAddresses = this.notReadyAddresses || [];
|
||||
return notReadyAddresses.map(a => new EndpointAddress(a));
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
if(!this.addresses) {
|
||||
return ""
|
||||
}
|
||||
return this.addresses.map(address => {
|
||||
if (!this.ports) {
|
||||
return address.ip
|
||||
}
|
||||
return this.ports.map(port => {
|
||||
return `${address.ip}:${port.port}`
|
||||
}).join(", ")
|
||||
}).join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
@autobind()
|
||||
export class Endpoint extends KubeObject {
|
||||
static kind = "Endpoint"
|
||||
|
||||
subsets: IEndpointSubset[]
|
||||
|
||||
getEndpointSubsets(): EndpointSubset[] {
|
||||
const subsets = this.subsets || [];
|
||||
return subsets.map(s => new EndpointSubset(s));
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
if(this.subsets) {
|
||||
return this.getEndpointSubsets().map(es => es.toString()).join(", ")
|
||||
} else {
|
||||
return "<none>"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const endpointApi = new KubeApi({
|
||||
kind: Endpoint.kind,
|
||||
apiBase: "/api/v1/endpoints",
|
||||
isNamespaced: true,
|
||||
objectConstructor: Endpoint,
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import moment from "moment";
|
||||
import { KubeObject } from "../kube-object";
|
||||
import { formatDuration } from "../../utils/formatDuration";
|
||||
import { autobind } from "../../utils";
|
||||
import { KubeApi } from "../kube-api";
|
||||
|
||||
@autobind()
|
||||
export class KubeEvent extends KubeObject {
|
||||
static kind = "Event"
|
||||
|
||||
involvedObject: {
|
||||
kind: string;
|
||||
namespace: string;
|
||||
name: string;
|
||||
uid: string;
|
||||
apiVersion: string;
|
||||
resourceVersion: string;
|
||||
fieldPath: string;
|
||||
}
|
||||
reason: string
|
||||
message: string
|
||||
source: {
|
||||
component: string;
|
||||
host: string;
|
||||
}
|
||||
firstTimestamp: string
|
||||
lastTimestamp: string
|
||||
count: number
|
||||
type: string
|
||||
eventTime: null
|
||||
reportingComponent: string
|
||||
reportingInstance: string
|
||||
|
||||
isWarning() {
|
||||
return this.type === "Warning";
|
||||
}
|
||||
|
||||
getSource() {
|
||||
const { component, host } = this.source
|
||||
return `${component} ${host || ""}`
|
||||
}
|
||||
|
||||
getFirstSeenTime() {
|
||||
const diff = moment().diff(this.firstTimestamp)
|
||||
return formatDuration(diff, true)
|
||||
}
|
||||
|
||||
getLastSeenTime() {
|
||||
const diff = moment().diff(this.lastTimestamp)
|
||||
return formatDuration(diff, true)
|
||||
}
|
||||
}
|
||||
|
||||
export const eventApi = new KubeApi({
|
||||
kind: KubeEvent.kind,
|
||||
apiBase: "/api/v1/events",
|
||||
isNamespaced: true,
|
||||
objectConstructor: KubeEvent,
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
import { compile } from "path-to-regexp";
|
||||
import { apiKubeHelm } from "../index";
|
||||
import { stringify } from "querystring";
|
||||
import { autobind } from "../../utils";
|
||||
|
||||
interface IHelmChartList {
|
||||
[repo: string]: {
|
||||
[name: string]: HelmChart;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IHelmChartDetails {
|
||||
readme: string;
|
||||
versions: HelmChart[];
|
||||
}
|
||||
|
||||
const endpoint = compile(`/v2/charts/:repo?/:name?`) as (params?: {
|
||||
repo?: string;
|
||||
name?: string;
|
||||
}) => string;
|
||||
|
||||
export const helmChartsApi = {
|
||||
list() {
|
||||
return apiKubeHelm
|
||||
.get<IHelmChartList>(endpoint())
|
||||
.then(data => {
|
||||
return Object
|
||||
.values(data)
|
||||
.reduce((allCharts, repoCharts) => allCharts.concat(Object.values(repoCharts)), [])
|
||||
.map(HelmChart.create);
|
||||
});
|
||||
},
|
||||
|
||||
get(repo: string, name: string, readmeVersion?: string) {
|
||||
const path = endpoint({ repo, name });
|
||||
return apiKubeHelm
|
||||
.get<IHelmChartDetails>(path + "?" + stringify({ version: readmeVersion }))
|
||||
.then(data => {
|
||||
const versions = data.versions.map(HelmChart.create);
|
||||
const readme = data.readme;
|
||||
return {
|
||||
readme,
|
||||
versions,
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
getValues(repo: string, name: string, version: string) {
|
||||
return apiKubeHelm
|
||||
.get<string>(`/v2/charts/${repo}/${name}/values?` + stringify({ version }));
|
||||
}
|
||||
};
|
||||
|
||||
@autobind()
|
||||
export class HelmChart {
|
||||
constructor(data: any) {
|
||||
Object.assign(this, data);
|
||||
}
|
||||
|
||||
static create(data: any) {
|
||||
return new HelmChart(data);
|
||||
}
|
||||
|
||||
apiVersion: string
|
||||
name: string
|
||||
version: string
|
||||
repo: string
|
||||
kubeVersion?: string
|
||||
created: string
|
||||
description?: string
|
||||
digest: string
|
||||
keywords?: string[]
|
||||
home?: string
|
||||
sources?: string[]
|
||||
maintainers?: {
|
||||
name: string;
|
||||
email: string;
|
||||
url: string;
|
||||
}[]
|
||||
engine?: string
|
||||
icon?: string
|
||||
appVersion?: string
|
||||
deprecated?: boolean
|
||||
tillerVersion?: string
|
||||
|
||||
getId() {
|
||||
return this.digest;
|
||||
}
|
||||
|
||||
getName() {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
getFullName(splitter = "/") {
|
||||
return [this.getRepository(), this.getName()].join(splitter);
|
||||
}
|
||||
|
||||
getDescription() {
|
||||
return this.description;
|
||||
}
|
||||
|
||||
getIcon() {
|
||||
return this.icon;
|
||||
}
|
||||
|
||||
getHome() {
|
||||
return this.home;
|
||||
}
|
||||
|
||||
getMaintainers() {
|
||||
return this.maintainers || [];
|
||||
}
|
||||
|
||||
getVersion() {
|
||||
return this.version;
|
||||
}
|
||||
|
||||
getRepository() {
|
||||
return this.repo;
|
||||
}
|
||||
|
||||
getAppVersion() {
|
||||
return this.appVersion || "";
|
||||
}
|
||||
|
||||
getKeywords() {
|
||||
return this.keywords || [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import jsYaml from "js-yaml";
|
||||
import { compile } from "path-to-regexp";
|
||||
import { autobind, formatDuration } from "../../utils";
|
||||
import capitalize from "lodash/capitalize";
|
||||
import { apiKubeHelm } from "../index";
|
||||
import { helmChartStore } from "../../components/+apps-helm-charts/helm-chart.store";
|
||||
import { ItemObject } from "../../item.store";
|
||||
import { KubeObject } from "../kube-object";
|
||||
|
||||
interface IReleasePayload {
|
||||
name: string;
|
||||
namespace: string;
|
||||
version: string;
|
||||
config: string; // release values
|
||||
manifest: string;
|
||||
info: {
|
||||
deleted: string;
|
||||
description: string;
|
||||
first_deployed: string;
|
||||
last_deployed: string;
|
||||
notes: string;
|
||||
status: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface IReleaseRawDetails extends IReleasePayload {
|
||||
resources: string;
|
||||
}
|
||||
|
||||
export interface IReleaseDetails extends IReleasePayload {
|
||||
resources: KubeObject[];
|
||||
}
|
||||
|
||||
export interface IReleaseCreatePayload {
|
||||
name?: string;
|
||||
repo: string;
|
||||
chart: string;
|
||||
namespace: string;
|
||||
version: string;
|
||||
values: string;
|
||||
}
|
||||
|
||||
export interface IReleaseUpdatePayload {
|
||||
repo: string;
|
||||
chart: string;
|
||||
version: string;
|
||||
values: string;
|
||||
}
|
||||
|
||||
export interface IReleaseUpdateDetails {
|
||||
log: string;
|
||||
release: IReleaseDetails;
|
||||
}
|
||||
|
||||
export interface IReleaseRevision {
|
||||
revision: number;
|
||||
updated: string;
|
||||
status: string;
|
||||
chart: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const endpoint = compile(`/v2/releases/:namespace?/:name?`) as (
|
||||
params?: {
|
||||
namespace?: string;
|
||||
name?: string;
|
||||
}
|
||||
) => string;
|
||||
|
||||
export const helmReleasesApi = {
|
||||
list(namespace?: string) {
|
||||
return apiKubeHelm
|
||||
.get<HelmRelease[]>(endpoint({ namespace }))
|
||||
.then(releases => releases.map(HelmRelease.create));
|
||||
},
|
||||
|
||||
get(name: string, namespace: string) {
|
||||
const path = endpoint({ name, namespace });
|
||||
return apiKubeHelm.get<IReleaseRawDetails>(path).then(details => {
|
||||
const items: KubeObject[] = JSON.parse(details.resources).items;
|
||||
const resources = items.map(item => KubeObject.create(item));
|
||||
return {
|
||||
...details,
|
||||
resources
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
create(payload: IReleaseCreatePayload): Promise<IReleaseUpdateDetails> {
|
||||
const { repo, ...data } = payload;
|
||||
data.chart = `${repo}/${data.chart}`;
|
||||
data.values = jsYaml.safeLoad(data.values);
|
||||
return apiKubeHelm.post(endpoint(), { data });
|
||||
},
|
||||
|
||||
update(name: string, namespace: string, payload: IReleaseUpdatePayload): Promise<IReleaseUpdateDetails> {
|
||||
const { repo, ...data } = payload;
|
||||
data.chart = `${repo}/${data.chart}`;
|
||||
data.values = jsYaml.safeLoad(data.values);
|
||||
return apiKubeHelm.put(endpoint({ name, namespace }), { data });
|
||||
},
|
||||
|
||||
async delete(name: string, namespace: string) {
|
||||
const path = endpoint({ name, namespace });
|
||||
return apiKubeHelm.del(path);
|
||||
},
|
||||
|
||||
getValues(name: string, namespace: string) {
|
||||
const path = endpoint({ name, namespace }) + "/values";
|
||||
return apiKubeHelm.get<string>(path);
|
||||
},
|
||||
|
||||
getHistory(name: string, namespace: string): Promise<IReleaseRevision[]> {
|
||||
const path = endpoint({ name, namespace }) + "/history";
|
||||
return apiKubeHelm.get(path);
|
||||
},
|
||||
|
||||
rollback(name: string, namespace: string, revision: number) {
|
||||
const path = endpoint({ name, namespace }) + "/rollback";
|
||||
return apiKubeHelm.put(path, {
|
||||
data: {
|
||||
revision: revision
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@autobind()
|
||||
export class HelmRelease implements ItemObject {
|
||||
constructor(data: any) {
|
||||
Object.assign(this, data);
|
||||
}
|
||||
|
||||
static create(data: any) {
|
||||
return new HelmRelease(data);
|
||||
}
|
||||
|
||||
appVersion: string
|
||||
name: string
|
||||
namespace: string
|
||||
chart: string
|
||||
status: string
|
||||
updated: string
|
||||
revision: number
|
||||
|
||||
getId() {
|
||||
return this.namespace + this.name;
|
||||
}
|
||||
|
||||
getName() {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
getNs() {
|
||||
return this.namespace;
|
||||
}
|
||||
|
||||
getChart(withVersion = false) {
|
||||
return withVersion ?
|
||||
this.chart :
|
||||
this.chart.substr(0, this.chart.lastIndexOf("-"));
|
||||
}
|
||||
|
||||
getRevision() {
|
||||
return this.revision;
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return capitalize(this.status);
|
||||
}
|
||||
|
||||
getVersion() {
|
||||
const versions = this.chart.match(/(\d+)[^-]*$/)
|
||||
if (versions) {
|
||||
return versions[0]
|
||||
}
|
||||
else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
getUpdated(humanize = true, compact = true) {
|
||||
const now = new Date().getTime();
|
||||
const updated = this.updated.replace(/\s\w*$/, ""); // 2019-11-26 10:58:09 +0300 MSK -> 2019-11-26 10:58:09 +0300 to pass into Date()
|
||||
const updatedDate = new Date(updated).getTime();
|
||||
const diff = now - updatedDate;
|
||||
if (humanize) {
|
||||
return formatDuration(diff, compact);
|
||||
}
|
||||
return diff;
|
||||
}
|
||||
|
||||
// Helm does not store from what repository the release is installed,
|
||||
// so we have to try to guess it by searching charts
|
||||
async getRepo() {
|
||||
const chartName = this.getChart();
|
||||
const version = this.getVersion();
|
||||
const versions = await helmChartStore.getVersions(chartName);
|
||||
const chartVersion = versions.find(chartVersion => chartVersion.version === version);
|
||||
return chartVersion ? chartVersion.repo : "";
|
||||
}
|
||||
|
||||
getLastVersion(): string | null {
|
||||
const chartName = this.getChart();
|
||||
const versions = helmChartStore.versions.get(chartName);
|
||||
if (!versions) {
|
||||
return null; // checking new version state
|
||||
}
|
||||
if (versions.length) {
|
||||
return versions[0].version; // versions already sorted when loaded, the first is latest
|
||||
}
|
||||
return this.getVersion();
|
||||
}
|
||||
|
||||
hasNewVersion() {
|
||||
const lastVersion = this.getLastVersion();
|
||||
return lastVersion && lastVersion !== this.getVersion();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { KubeObject } from "../kube-object";
|
||||
import { KubeApi } from "../kube-api";
|
||||
|
||||
export enum HpaMetricType {
|
||||
Resource = "Resource",
|
||||
Pods = "Pods",
|
||||
Object = "Object",
|
||||
External = "External",
|
||||
}
|
||||
|
||||
export type IHpaMetricData<T = any> = T & {
|
||||
target?: {
|
||||
kind: string;
|
||||
name: string;
|
||||
apiVersion: string;
|
||||
};
|
||||
name?: string;
|
||||
metricName?: string;
|
||||
currentAverageUtilization?: number;
|
||||
currentAverageValue?: string;
|
||||
targetAverageUtilization?: number;
|
||||
targetAverageValue?: string;
|
||||
}
|
||||
|
||||
export interface IHpaMetric {
|
||||
[kind: string]: IHpaMetricData;
|
||||
|
||||
type: HpaMetricType;
|
||||
resource?: IHpaMetricData<{ name: string }>;
|
||||
pods?: IHpaMetricData;
|
||||
external?: IHpaMetricData;
|
||||
object?: IHpaMetricData<{
|
||||
describedObject: {
|
||||
apiVersion: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export class HorizontalPodAutoscaler extends KubeObject {
|
||||
static kind = "HorizontalPodAutoscaler";
|
||||
|
||||
spec: {
|
||||
scaleTargetRef: {
|
||||
kind: string;
|
||||
name: string;
|
||||
apiVersion: string;
|
||||
};
|
||||
minReplicas: number;
|
||||
maxReplicas: number;
|
||||
metrics: IHpaMetric[];
|
||||
}
|
||||
status: {
|
||||
currentReplicas: number;
|
||||
desiredReplicas: number;
|
||||
currentMetrics: IHpaMetric[];
|
||||
conditions: {
|
||||
lastTransitionTime: string;
|
||||
message: string;
|
||||
reason: string;
|
||||
status: string;
|
||||
type: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
getMaxPods() {
|
||||
return this.spec.maxReplicas || 0;
|
||||
}
|
||||
|
||||
getMinPods() {
|
||||
return this.spec.minReplicas || 0;
|
||||
}
|
||||
|
||||
getReplicas() {
|
||||
return this.status.currentReplicas;
|
||||
}
|
||||
|
||||
getConditions() {
|
||||
if (!this.status.conditions) return [];
|
||||
return this.status.conditions.map(condition => {
|
||||
const { message, reason, lastTransitionTime, status } = condition;
|
||||
return {
|
||||
...condition,
|
||||
isReady: status === "True",
|
||||
tooltip: `${message || reason} (${lastTransitionTime})`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getMetrics() {
|
||||
return this.spec.metrics || [];
|
||||
}
|
||||
|
||||
getCurrentMetrics() {
|
||||
return this.status.currentMetrics || [];
|
||||
}
|
||||
|
||||
protected getMetricName(metric: IHpaMetric): string {
|
||||
const { type, resource, pods, object, external } = metric;
|
||||
switch (type) {
|
||||
case HpaMetricType.Resource:
|
||||
return resource.name
|
||||
case HpaMetricType.Pods:
|
||||
return pods.metricName;
|
||||
case HpaMetricType.Object:
|
||||
return object.metricName;
|
||||
case HpaMetricType.External:
|
||||
return external.metricName;
|
||||
}
|
||||
}
|
||||
|
||||
// todo: refactor
|
||||
getMetricValues(metric: IHpaMetric): string {
|
||||
const metricType = metric.type.toLowerCase();
|
||||
const currentMetric = this.getCurrentMetrics().find(current =>
|
||||
metric.type == current.type && this.getMetricName(metric) == this.getMetricName(current)
|
||||
);
|
||||
const current = currentMetric ? currentMetric[metricType] : null;
|
||||
const target = metric[metricType];
|
||||
let currentValue = "unknown";
|
||||
let targetValue = "unknown";
|
||||
if (current) {
|
||||
currentValue = current.currentAverageUtilization || current.currentAverageValue || current.currentValue;
|
||||
if (current.currentAverageUtilization) currentValue += "%";
|
||||
}
|
||||
if (target) {
|
||||
targetValue = target.targetAverageUtilization || target.targetAverageValue || target.targetValue;
|
||||
if (target.targetAverageUtilization) targetValue += "%"
|
||||
}
|
||||
return `${currentValue} / ${targetValue}`;
|
||||
}
|
||||
}
|
||||
|
||||
export const hpaApi = new KubeApi({
|
||||
kind: HorizontalPodAutoscaler.kind,
|
||||
apiBase: "/apis/autoscaling/v2beta1/horizontalpodautoscalers",
|
||||
isNamespaced: true,
|
||||
objectConstructor: HorizontalPodAutoscaler,
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
// Local express.js endpoints
|
||||
export * from "./config.api"
|
||||
export * from "./cluster.api"
|
||||
export * from "./kubeconfig.api"
|
||||
|
||||
// Kubernetes endpoints
|
||||
// Docs: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.10/
|
||||
export * from "./namespaces.api"
|
||||
export * from "./cluster-role.api"
|
||||
export * from "./cluster-role-binding.api"
|
||||
export * from "./role.api"
|
||||
export * from "./role-binding.api"
|
||||
export * from "./secret.api"
|
||||
export * from "./service-accounts.api"
|
||||
export * from "./nodes.api"
|
||||
export * from "./pods.api"
|
||||
export * from "./deployment.api"
|
||||
export * from "./daemon-set.api"
|
||||
export * from "./stateful-set.api"
|
||||
export * from "./replica-set.api"
|
||||
export * from "./job.api"
|
||||
export * from "./cron-job.api"
|
||||
export * from "./configmap.api"
|
||||
export * from "./ingress.api"
|
||||
export * from "./network-policy.api"
|
||||
export * from "./persistent-volume-claims.api"
|
||||
export * from "./persistent-volume.api"
|
||||
export * from "./service.api"
|
||||
export * from "./endpoint.api"
|
||||
export * from "./storage-class.api"
|
||||
export * from "./pod-metrics.api"
|
||||
export * from "./podsecuritypolicy.api"
|
||||
export * from "./selfsubjectrulesreviews.api"
|
||||
@@ -0,0 +1,113 @@
|
||||
import { KubeObject } from "../kube-object";
|
||||
import { autobind } from "../../utils";
|
||||
import { IMetrics, metricsApi } from "./metrics.api";
|
||||
import { KubeApi } from "../kube-api";
|
||||
|
||||
export class IngressApi extends KubeApi<Ingress> {
|
||||
getMetrics(ingress: string, namespace: string): Promise<IIngressMetrics> {
|
||||
const opts = { category: "ingress", ingress }
|
||||
return metricsApi.getMetrics({
|
||||
bytesSentSuccess: opts,
|
||||
bytesSentFailure: opts,
|
||||
requestDurationSeconds: opts,
|
||||
responseDurationSeconds: opts
|
||||
}, {
|
||||
namespace,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export interface IIngressMetrics<T = IMetrics> {
|
||||
[metric: string]: T;
|
||||
bytesSentSuccess: T;
|
||||
bytesSentFailure: T;
|
||||
requestDurationSeconds: T;
|
||||
responseDurationSeconds: T;
|
||||
}
|
||||
|
||||
@autobind()
|
||||
export class Ingress extends KubeObject {
|
||||
static kind = "Ingress"
|
||||
|
||||
spec: {
|
||||
tls: {
|
||||
secretName: string;
|
||||
}[];
|
||||
rules?: {
|
||||
host?: string;
|
||||
http: {
|
||||
paths: {
|
||||
path?: string;
|
||||
backend: {
|
||||
serviceName: string;
|
||||
servicePort: number;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
}[];
|
||||
backend?: {
|
||||
serviceName: string;
|
||||
servicePort: number;
|
||||
};
|
||||
}
|
||||
status: {
|
||||
loadBalancer: {
|
||||
ingress: any[];
|
||||
};
|
||||
}
|
||||
|
||||
getRoutes() {
|
||||
const { spec: { tls, rules } } = this
|
||||
if (!rules) return []
|
||||
|
||||
let protocol = "http"
|
||||
const routes: string[] = []
|
||||
if (tls && tls.length > 0) {
|
||||
protocol += "s"
|
||||
}
|
||||
rules.map(rule => {
|
||||
const host = rule.host ? rule.host : "*"
|
||||
if (rule.http && rule.http.paths) {
|
||||
rule.http.paths.forEach(path => {
|
||||
routes.push(protocol + "://" + host + (path.path || "/") + " ⇢ " + path.backend.serviceName + ":" + path.backend.servicePort)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
getHosts() {
|
||||
const { spec: { rules } } = this
|
||||
if (!rules) return []
|
||||
return rules.filter(rule => rule.host).map(rule => rule.host)
|
||||
}
|
||||
|
||||
getPorts() {
|
||||
const ports: number[] = []
|
||||
const { spec: { tls, rules, backend } } = this
|
||||
const httpPort = 80
|
||||
const tlsPort = 443
|
||||
if (rules && rules.length > 0) {
|
||||
if (rules.some(rule => rule.hasOwnProperty("http"))) {
|
||||
ports.push(httpPort)
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (backend && backend.servicePort) {
|
||||
ports.push(backend.servicePort)
|
||||
}
|
||||
}
|
||||
if (tls && tls.length > 0) {
|
||||
ports.push(tlsPort)
|
||||
}
|
||||
return ports.join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
export const ingressApi = new IngressApi({
|
||||
kind: Ingress.kind,
|
||||
apiBase: "/apis/extensions/v1beta1/ingresses",
|
||||
isNamespaced: true,
|
||||
objectConstructor: Ingress,
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import get from "lodash/get";
|
||||
import { autobind } from "../../utils";
|
||||
import { IAffinity, WorkloadKubeObject } from "../workload-kube-object";
|
||||
import { IPodContainer } from "./pods.api";
|
||||
import { KubeApi } from "../kube-api";
|
||||
|
||||
@autobind()
|
||||
export class Job extends WorkloadKubeObject {
|
||||
static kind = "Job"
|
||||
|
||||
spec: {
|
||||
parallelism?: number;
|
||||
completions?: number;
|
||||
backoffLimit?: number;
|
||||
selector: {
|
||||
matchLabels: {
|
||||
[name: string]: string;
|
||||
};
|
||||
};
|
||||
template: {
|
||||
metadata: {
|
||||
creationTimestamp?: string;
|
||||
labels: {
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
spec: {
|
||||
containers: IPodContainer[];
|
||||
restartPolicy: string;
|
||||
terminationGracePeriodSeconds: number;
|
||||
dnsPolicy: string;
|
||||
hostPID: boolean;
|
||||
affinity?: IAffinity;
|
||||
nodeSelector?: {
|
||||
[selector: string]: string;
|
||||
};
|
||||
tolerations: {
|
||||
key: string;
|
||||
operator: string;
|
||||
effect: string;
|
||||
tolerationSeconds: number;
|
||||
}[];
|
||||
schedulerName: string;
|
||||
};
|
||||
};
|
||||
containers?: IPodContainer[];
|
||||
restartPolicy?: string;
|
||||
terminationGracePeriodSeconds?: number;
|
||||
dnsPolicy?: string;
|
||||
serviceAccountName?: string;
|
||||
serviceAccount?: string;
|
||||
schedulerName?: string;
|
||||
}
|
||||
status: {
|
||||
conditions: {
|
||||
type: string;
|
||||
status: string;
|
||||
lastProbeTime: string;
|
||||
lastTransitionTime: string;
|
||||
message?: string;
|
||||
}[];
|
||||
startTime: string;
|
||||
completionTime: string;
|
||||
succeeded: number;
|
||||
}
|
||||
|
||||
getDesiredCompletions() {
|
||||
return this.spec.completions || 0;
|
||||
}
|
||||
|
||||
getCompletions() {
|
||||
return this.status.succeeded || 0;
|
||||
}
|
||||
|
||||
getParallelism() {
|
||||
return this.spec.parallelism;
|
||||
}
|
||||
|
||||
getCondition() {
|
||||
// Type of Job condition could be only Complete or Failed
|
||||
// https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.14/#jobcondition-v1-batch
|
||||
const { conditions } = this.status;
|
||||
if (!conditions) return;
|
||||
return conditions.find(({ status }) => status === "True");
|
||||
}
|
||||
|
||||
getImages() {
|
||||
const containers: IPodContainer[] = get(this, "spec.template.spec.containers", [])
|
||||
return [...containers].map(container => container.image)
|
||||
}
|
||||
}
|
||||
|
||||
export const jobApi = new KubeApi({
|
||||
kind: Job.kind,
|
||||
apiBase: "/apis/batch/v1/jobs",
|
||||
isNamespaced: true,
|
||||
objectConstructor: Job,
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
// Kubeconfig api
|
||||
import { apiBase } from "../index";
|
||||
|
||||
export const kubeConfigApi = {
|
||||
getUserConfig() {
|
||||
return apiBase.get("/kubeconfig/user");
|
||||
},
|
||||
|
||||
getServiceAccountConfig(account: string, namespace: string) {
|
||||
return apiBase.get(`/kubeconfig/service-account/${namespace}/${account}`);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
// Metrics api
|
||||
|
||||
import moment from "moment";
|
||||
import { apiBase } from "../index";
|
||||
import type { IMetricsQuery } from "../../../main/routes/metrics";
|
||||
|
||||
export interface IMetrics {
|
||||
status: string;
|
||||
data: {
|
||||
resultType: string;
|
||||
result: IMetricsResult[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IMetricsResult {
|
||||
metric: {
|
||||
[name: string]: string;
|
||||
instance: string;
|
||||
node?: string;
|
||||
pod?: string;
|
||||
kubernetes?: string;
|
||||
kubernetes_node?: string;
|
||||
kubernetes_namespace?: string;
|
||||
};
|
||||
values: [number, string][];
|
||||
}
|
||||
|
||||
export interface IMetricsReqParams {
|
||||
start?: number | string; // timestamp in seconds or valid date-string
|
||||
end?: number | string;
|
||||
step?: number; // step in seconds (default: 60s = each point 1m)
|
||||
range?: number; // time-range in seconds for data aggregation (default: 3600s = last 1h)
|
||||
namespace?: string; // rbac-proxy validation param
|
||||
}
|
||||
|
||||
export const metricsApi = {
|
||||
async getMetrics<T = IMetricsQuery>(query: T, reqParams: IMetricsReqParams = {}): Promise<T extends object ? { [K in keyof T]: IMetrics } : IMetrics> {
|
||||
const { range = 3600, step = 60, namespace } = reqParams;
|
||||
let { start, end } = reqParams;
|
||||
|
||||
if (!start && !end) {
|
||||
const timeNow = Date.now() / 1000;
|
||||
const now = moment.unix(timeNow).startOf('minute').unix(); // round date to minutes
|
||||
start = now - range;
|
||||
end = now;
|
||||
}
|
||||
|
||||
return apiBase.post("/metrics", {
|
||||
data: query,
|
||||
query: {
|
||||
start, end, step,
|
||||
"kubernetes_namespace": namespace,
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export function normalizeMetrics(metrics: IMetrics, frames = 60): IMetrics {
|
||||
const { result } = metrics.data;
|
||||
if (result.length) {
|
||||
if (frames > 0) {
|
||||
// fill the gaps
|
||||
result.forEach(res => {
|
||||
if (!res.values || !res.values.length) return;
|
||||
while (res.values.length < frames) {
|
||||
const timestamp = moment.unix(res.values[0][0]).subtract(1, "minute").unix();
|
||||
res.values.unshift([timestamp, "0"])
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
else {
|
||||
// always return at least empty values array
|
||||
result.push({
|
||||
metric: {},
|
||||
values: []
|
||||
} as IMetricsResult);
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
export function isMetricsEmpty(metrics: { [key: string]: IMetrics }) {
|
||||
return Object.values(metrics).every(metric => !metric.data.result.length);
|
||||
}
|
||||
|
||||
export function getItemMetrics(metrics: { [key: string]: IMetrics }, itemName: string) {
|
||||
if (!metrics) return;
|
||||
const itemMetrics = { ...metrics };
|
||||
for (const metric in metrics) {
|
||||
const results = metrics[metric].data.result;
|
||||
const result = results.find(res => Object.values(res.metric)[0] == itemName);
|
||||
itemMetrics[metric].data.result = result ? [result] : [];
|
||||
}
|
||||
return itemMetrics;
|
||||
}
|
||||
|
||||
export function getMetricLastPoints(metrics: { [key: string]: IMetrics }) {
|
||||
const result: Partial<{ [metric: string]: number }> = {};
|
||||
|
||||
Object.keys(metrics).forEach(metricName => {
|
||||
try {
|
||||
const metric = metrics[metricName];
|
||||
if (metric.data.result.length) {
|
||||
result[metricName] = +metric.data.result[0].values.slice(-1)[0][1];
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
return result;
|
||||
}, {});
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { KubeApi } from "../kube-api";
|
||||
import { KubeObject } from "../kube-object";
|
||||
import { autobind } from "../../utils";
|
||||
|
||||
export enum NamespaceStatus {
|
||||
ACTIVE = "Active",
|
||||
TERMINATING = "Terminating",
|
||||
}
|
||||
|
||||
@autobind()
|
||||
export class Namespace extends KubeObject {
|
||||
static kind = "Namespace";
|
||||
|
||||
status?: {
|
||||
phase: string;
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return this.status ? this.status.phase : "-";
|
||||
}
|
||||
}
|
||||
|
||||
export const namespacesApi = new KubeApi({
|
||||
kind: Namespace.kind,
|
||||
apiBase: "/api/v1/namespaces",
|
||||
isNamespaced: false,
|
||||
objectConstructor: Namespace,
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { KubeObject } from "../kube-object";
|
||||
import { autobind } from "../../utils";
|
||||
import { KubeApi } from "../kube-api";
|
||||
|
||||
export interface IPolicyIpBlock {
|
||||
cidr: string;
|
||||
except?: string[];
|
||||
}
|
||||
|
||||
export interface IPolicySelector {
|
||||
matchLabels: {
|
||||
[label: string]: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IPolicyIngress {
|
||||
from: {
|
||||
ipBlock?: IPolicyIpBlock;
|
||||
namespaceSelector?: IPolicySelector;
|
||||
podSelector?: IPolicySelector;
|
||||
}[];
|
||||
ports: {
|
||||
protocol: string;
|
||||
port: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface IPolicyEgress {
|
||||
to: {
|
||||
ipBlock: IPolicyIpBlock;
|
||||
}[];
|
||||
ports: {
|
||||
protocol: string;
|
||||
port: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
@autobind()
|
||||
export class NetworkPolicy extends KubeObject {
|
||||
static kind = "NetworkPolicy"
|
||||
|
||||
spec: {
|
||||
podSelector: {
|
||||
matchLabels: {
|
||||
[label: string]: string;
|
||||
role: string;
|
||||
};
|
||||
};
|
||||
policyTypes: string[];
|
||||
ingress: IPolicyIngress[];
|
||||
egress: IPolicyEgress[];
|
||||
}
|
||||
|
||||
getMatchLabels(): string[] {
|
||||
if (!this.spec.podSelector || !this.spec.podSelector.matchLabels) return [];
|
||||
return Object
|
||||
.entries(this.spec.podSelector.matchLabels)
|
||||
.map(data => data.join(":"))
|
||||
}
|
||||
|
||||
getTypes(): string[] {
|
||||
if (!this.spec.policyTypes) return [];
|
||||
return this.spec.policyTypes;
|
||||
}
|
||||
}
|
||||
|
||||
export const networkPolicyApi = new KubeApi({
|
||||
kind: NetworkPolicy.kind,
|
||||
apiBase: "/apis/networking.k8s.io/v1/networkpolicies",
|
||||
isNamespaced: true,
|
||||
objectConstructor: NetworkPolicy,
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { KubeObject } from "../kube-object";
|
||||
import { autobind, cpuUnitsToNumber, unitsToBytes } from "../../utils";
|
||||
import { IMetrics, metricsApi } from "./metrics.api";
|
||||
import { KubeApi } from "../kube-api";
|
||||
|
||||
export class NodesApi extends KubeApi<Node> {
|
||||
getMetrics(): Promise<INodeMetrics> {
|
||||
const opts = { category: "nodes"}
|
||||
|
||||
return metricsApi.getMetrics({
|
||||
memoryUsage: opts,
|
||||
memoryCapacity: opts,
|
||||
cpuUsage: opts,
|
||||
cpuCapacity: opts,
|
||||
fsSize: opts,
|
||||
fsUsage: opts
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export interface INodeMetrics<T = IMetrics> {
|
||||
[metric: string]: T;
|
||||
memoryUsage: T;
|
||||
memoryCapacity: T;
|
||||
cpuUsage: T;
|
||||
cpuCapacity: T;
|
||||
fsUsage: T;
|
||||
fsSize: T;
|
||||
}
|
||||
|
||||
@autobind()
|
||||
export class Node extends KubeObject {
|
||||
static kind = "Node"
|
||||
|
||||
spec: {
|
||||
podCIDR: string;
|
||||
externalID: string;
|
||||
taints?: {
|
||||
key: string;
|
||||
value: string;
|
||||
effect: string;
|
||||
}[];
|
||||
unschedulable?: boolean;
|
||||
}
|
||||
status: {
|
||||
capacity: {
|
||||
cpu: string;
|
||||
memory: string;
|
||||
pods: string;
|
||||
};
|
||||
allocatable: {
|
||||
cpu: string;
|
||||
memory: string;
|
||||
pods: string;
|
||||
};
|
||||
conditions: {
|
||||
type: string;
|
||||
status?: string;
|
||||
lastHeartbeatTime?: string;
|
||||
lastTransitionTime?: string;
|
||||
reason?: string;
|
||||
message?: string;
|
||||
}[];
|
||||
addresses: {
|
||||
type: string;
|
||||
address: string;
|
||||
}[];
|
||||
nodeInfo: {
|
||||
machineID: string;
|
||||
systemUUID: string;
|
||||
bootID: string;
|
||||
kernelVersion: string;
|
||||
osImage: string;
|
||||
containerRuntimeVersion: string;
|
||||
kubeletVersion: string;
|
||||
kubeProxyVersion: string;
|
||||
operatingSystem: string;
|
||||
architecture: string;
|
||||
};
|
||||
images: {
|
||||
names: string[];
|
||||
sizeBytes: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
getNodeConditionText() {
|
||||
const { conditions } = this.status
|
||||
if (!conditions) return ""
|
||||
return conditions.reduce((types, current) => {
|
||||
if (current.status !== "True") return ""
|
||||
return types += ` ${current.type}`
|
||||
}, "")
|
||||
}
|
||||
|
||||
getTaints() {
|
||||
return this.spec.taints || [];
|
||||
}
|
||||
|
||||
getRoleLabels() {
|
||||
const roleLabels = Object.keys(this.metadata.labels).filter(key =>
|
||||
key.includes("node-role.kubernetes.io")
|
||||
).map(key => key.match(/([^/]+$)/)[0]) // all after last slash
|
||||
|
||||
if (this.metadata.labels["kubernetes.io/role"] != undefined) {
|
||||
roleLabels.push(this.metadata.labels["kubernetes.io/role"])
|
||||
}
|
||||
|
||||
return roleLabels.join(", ")
|
||||
}
|
||||
|
||||
getCpuCapacity() {
|
||||
if (!this.status.capacity || !this.status.capacity.cpu) return 0
|
||||
return cpuUnitsToNumber(this.status.capacity.cpu)
|
||||
}
|
||||
|
||||
getMemoryCapacity() {
|
||||
if (!this.status.capacity || !this.status.capacity.memory) return 0
|
||||
return unitsToBytes(this.status.capacity.memory)
|
||||
}
|
||||
|
||||
getConditions() {
|
||||
const conditions = this.status.conditions || [];
|
||||
if (this.isUnschedulable()) {
|
||||
return [{ type: "SchedulingDisabled", status: "True" }, ...conditions];
|
||||
}
|
||||
return conditions;
|
||||
}
|
||||
|
||||
getActiveConditions() {
|
||||
return this.getConditions().filter(c => c.status === "True");
|
||||
}
|
||||
|
||||
getWarningConditions() {
|
||||
const goodConditions = ["Ready", "HostUpgrades", "SchedulingDisabled"];
|
||||
return this.getActiveConditions().filter(condition => {
|
||||
return !goodConditions.includes(condition.type);
|
||||
});
|
||||
}
|
||||
|
||||
getKubeletVersion() {
|
||||
return this.status.nodeInfo.kubeletVersion;
|
||||
}
|
||||
|
||||
getOperatingSystem(): string {
|
||||
const label = this.getLabels().find(label => label.startsWith("kubernetes.io/os="))
|
||||
if (label) {
|
||||
return label.split("=", 2)[1]
|
||||
}
|
||||
|
||||
return "linux"
|
||||
}
|
||||
|
||||
isUnschedulable() {
|
||||
return this.spec.unschedulable
|
||||
}
|
||||
}
|
||||
|
||||
export const nodesApi = new NodesApi({
|
||||
kind: Node.kind,
|
||||
apiBase: "/api/v1/nodes",
|
||||
isNamespaced: false,
|
||||
objectConstructor: Node,
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { KubeObject } from "../kube-object";
|
||||
import { autobind } from "../../utils";
|
||||
import { IMetrics, metricsApi } from "./metrics.api";
|
||||
import { Pod } from "./pods.api";
|
||||
import { KubeApi } from "../kube-api";
|
||||
|
||||
export class PersistentVolumeClaimsApi extends KubeApi<PersistentVolumeClaim> {
|
||||
getMetrics(pvcName: string, namespace: string): Promise<IPvcMetrics> {
|
||||
return metricsApi.getMetrics({
|
||||
diskUsage: { category: 'pvc', pvc: pvcName },
|
||||
diskCapacity: { category: 'pvc', pvc: pvcName }
|
||||
}, {
|
||||
namespace
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export interface IPvcMetrics<T = IMetrics> {
|
||||
[key: string]: T;
|
||||
diskUsage: T;
|
||||
diskCapacity: T;
|
||||
}
|
||||
|
||||
@autobind()
|
||||
export class PersistentVolumeClaim extends KubeObject {
|
||||
static kind = "PersistentVolumeClaim"
|
||||
|
||||
spec: {
|
||||
accessModes: string[];
|
||||
storageClassName: string;
|
||||
selector: {
|
||||
matchLabels: {
|
||||
release: string;
|
||||
};
|
||||
matchExpressions: {
|
||||
key: string; // environment,
|
||||
operator: string; // In,
|
||||
values: string[]; // [dev]
|
||||
}[];
|
||||
};
|
||||
resources: {
|
||||
requests: {
|
||||
storage: string; // 8Gi
|
||||
};
|
||||
};
|
||||
}
|
||||
status: {
|
||||
phase: string; // Pending
|
||||
}
|
||||
|
||||
getPods(allPods: Pod[]): Pod[] {
|
||||
const pods = allPods.filter(pod => pod.getNs() === this.getNs())
|
||||
return pods.filter(pod => {
|
||||
return pod.getVolumes().filter(volume =>
|
||||
volume.persistentVolumeClaim &&
|
||||
volume.persistentVolumeClaim.claimName === this.getName()
|
||||
).length > 0
|
||||
})
|
||||
}
|
||||
|
||||
getStorage(): string {
|
||||
if (!this.spec.resources || !this.spec.resources.requests) return "-";
|
||||
return this.spec.resources.requests.storage;
|
||||
}
|
||||
|
||||
getMatchLabels(): string[] {
|
||||
if (!this.spec.selector || !this.spec.selector.matchLabels) return [];
|
||||
return Object.entries(this.spec.selector.matchLabels)
|
||||
.map(([name, val]) => `${name}:${val}`);
|
||||
}
|
||||
|
||||
getMatchExpressions() {
|
||||
if (!this.spec.selector || !this.spec.selector.matchExpressions) return [];
|
||||
return this.spec.selector.matchExpressions;
|
||||
}
|
||||
|
||||
getStatus(): string {
|
||||
if (this.status) return this.status.phase;
|
||||
return "-"
|
||||
}
|
||||
}
|
||||
|
||||
export const pvcApi = new PersistentVolumeClaimsApi({
|
||||
kind: PersistentVolumeClaim.kind,
|
||||
apiBase: "/api/v1/persistentvolumeclaims",
|
||||
isNamespaced: true,
|
||||
objectConstructor: PersistentVolumeClaim,
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { KubeObject } from "../kube-object";
|
||||
import { unitsToBytes } from "../../utils/convertMemory";
|
||||
import { autobind } from "../../utils";
|
||||
import { KubeApi } from "../kube-api";
|
||||
|
||||
@autobind()
|
||||
export class PersistentVolume extends KubeObject {
|
||||
static kind = "PersistentVolume"
|
||||
|
||||
spec: {
|
||||
capacity: {
|
||||
storage: string; // 8Gi
|
||||
};
|
||||
flexVolume: {
|
||||
driver: string; // ceph.rook.io/rook-ceph-system,
|
||||
options: {
|
||||
clusterNamespace: string; // rook-ceph,
|
||||
image: string; // pvc-c5d7c485-9f1b-11e8-b0ea-9600000e54fb,
|
||||
pool: string; // replicapool,
|
||||
storageClass: string; // rook-ceph-block
|
||||
};
|
||||
};
|
||||
mountOptions?: string[];
|
||||
accessModes: string[]; // [ReadWriteOnce]
|
||||
claimRef: {
|
||||
kind: string; // PersistentVolumeClaim,
|
||||
namespace: string; // storage,
|
||||
name: string; // nfs-provisioner,
|
||||
uid: string; // c5d7c485-9f1b-11e8-b0ea-9600000e54fb,
|
||||
apiVersion: string; // v1,
|
||||
resourceVersion: string; // 292180
|
||||
};
|
||||
persistentVolumeReclaimPolicy: string; // Delete,
|
||||
storageClassName: string; // rook-ceph-block
|
||||
nfs?: {
|
||||
path: string;
|
||||
server: string;
|
||||
};
|
||||
}
|
||||
|
||||
status: {
|
||||
phase: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
getCapacity(inBytes = false) {
|
||||
const capacity = this.spec.capacity;
|
||||
if (capacity) {
|
||||
if (inBytes) return unitsToBytes(capacity.storage)
|
||||
return capacity.storage;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
if (!this.status) return;
|
||||
return this.status.phase || "-";
|
||||
}
|
||||
|
||||
getClaimRefName() {
|
||||
const { claimRef } = this.spec;
|
||||
return claimRef ? claimRef.name : "";
|
||||
}
|
||||
}
|
||||
|
||||
export const persistentVolumeApi = new KubeApi({
|
||||
kind: PersistentVolume.kind,
|
||||
apiBase: "/api/v1/persistentvolumes",
|
||||
isNamespaced: false,
|
||||
objectConstructor: PersistentVolume,
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { KubeObject } from "../kube-object";
|
||||
import { KubeApi } from "../kube-api";
|
||||
|
||||
export class PodMetrics extends KubeObject {
|
||||
timestamp: string
|
||||
window: string
|
||||
containers: {
|
||||
name: string;
|
||||
usage: {
|
||||
cpu: string;
|
||||
memory: string;
|
||||
};
|
||||
}[]
|
||||
}
|
||||
|
||||
export const podMetricsApi = new KubeApi({
|
||||
kind: PodMetrics.kind,
|
||||
apiBase: "/apis/metrics.k8s.io/v1beta1/pods",
|
||||
isNamespaced: true,
|
||||
objectConstructor: PodMetrics,
|
||||
});
|
||||
@@ -0,0 +1,423 @@
|
||||
import { IAffinity, WorkloadKubeObject } from "../workload-kube-object";
|
||||
import { autobind } from "../../utils";
|
||||
import { IMetrics, metricsApi } from "./metrics.api";
|
||||
import { KubeApi } from "../kube-api";
|
||||
|
||||
export class PodsApi extends KubeApi<Pod> {
|
||||
async getLogs(params: { namespace: string; name: string }, query?: IPodLogsQuery): Promise<string> {
|
||||
const path = this.getUrl(params) + "/log";
|
||||
return this.request.get(path, { query });
|
||||
}
|
||||
|
||||
getMetrics(pods: Pod[], namespace: string, selector = "pod, namespace"): Promise<IPodMetrics> {
|
||||
const podSelector = pods.map(pod => pod.getName()).join("|");
|
||||
const opts = { category: "pods", pods: podSelector, namespace, selector }
|
||||
|
||||
return metricsApi.getMetrics({
|
||||
cpuUsage: opts,
|
||||
cpuRequests: opts,
|
||||
cpuLimits: opts,
|
||||
memoryUsage: opts,
|
||||
memoryRequests: opts,
|
||||
memoryLimits: opts,
|
||||
fsUsage: opts,
|
||||
networkReceive: opts,
|
||||
networkTransmit: opts,
|
||||
}, {
|
||||
namespace,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export interface IPodMetrics<T = IMetrics> {
|
||||
[metric: string]: T;
|
||||
cpuUsage: T;
|
||||
cpuRequests: T;
|
||||
cpuLimits: T;
|
||||
memoryUsage: T;
|
||||
memoryRequests: T;
|
||||
memoryLimits: T;
|
||||
fsUsage: T;
|
||||
networkReceive: T;
|
||||
networkTransmit: T;
|
||||
}
|
||||
|
||||
export interface IPodLogsQuery {
|
||||
container?: string;
|
||||
tailLines?: number;
|
||||
timestamps?: boolean;
|
||||
sinceTime?: string; // Date.toISOString()-format
|
||||
}
|
||||
|
||||
export enum PodStatus {
|
||||
TERMINATED = "Terminated",
|
||||
FAILED = "Failed",
|
||||
PENDING = "Pending",
|
||||
RUNNING = "Running",
|
||||
SUCCEEDED = "Succeeded",
|
||||
EVICTED = "Evicted"
|
||||
}
|
||||
|
||||
export interface IPodContainer {
|
||||
name: string;
|
||||
image: string;
|
||||
command?: string[];
|
||||
args?: string[];
|
||||
ports: {
|
||||
name?: string;
|
||||
containerPort: number;
|
||||
protocol: string;
|
||||
}[];
|
||||
resources?: {
|
||||
limits: {
|
||||
cpu: string;
|
||||
memory: string;
|
||||
};
|
||||
requests: {
|
||||
cpu: string;
|
||||
memory: string;
|
||||
};
|
||||
};
|
||||
env?: {
|
||||
name: string;
|
||||
value?: string;
|
||||
valueFrom?: {
|
||||
fieldRef?: {
|
||||
apiVersion: string;
|
||||
fieldPath: string;
|
||||
};
|
||||
secretKeyRef?: {
|
||||
key: string;
|
||||
name: string;
|
||||
};
|
||||
configMapKeyRef?: {
|
||||
key: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
}[];
|
||||
envFrom?: {
|
||||
configMapRef?: {
|
||||
name: string;
|
||||
};
|
||||
}[];
|
||||
volumeMounts?: {
|
||||
name: string;
|
||||
readOnly: boolean;
|
||||
mountPath: string;
|
||||
}[];
|
||||
livenessProbe?: IContainerProbe;
|
||||
readinessProbe?: IContainerProbe;
|
||||
imagePullPolicy: string;
|
||||
}
|
||||
|
||||
interface IContainerProbe {
|
||||
httpGet?: {
|
||||
path?: string;
|
||||
port: number;
|
||||
scheme: string;
|
||||
host?: string;
|
||||
};
|
||||
exec?: {
|
||||
command: string[];
|
||||
};
|
||||
tcpSocket?: {
|
||||
port: number;
|
||||
};
|
||||
initialDelaySeconds?: number;
|
||||
timeoutSeconds?: number;
|
||||
periodSeconds?: number;
|
||||
successThreshold?: number;
|
||||
failureThreshold?: number;
|
||||
}
|
||||
|
||||
export interface IPodContainerStatus {
|
||||
name: string;
|
||||
state: {
|
||||
[index: string]: object;
|
||||
running?: {
|
||||
startedAt: string;
|
||||
};
|
||||
waiting?: {
|
||||
reason: string;
|
||||
message: string;
|
||||
};
|
||||
terminated?: {
|
||||
startedAt: string;
|
||||
finishedAt: string;
|
||||
exitCode: number;
|
||||
reason: string;
|
||||
};
|
||||
};
|
||||
lastState: {};
|
||||
ready: boolean;
|
||||
restartCount: number;
|
||||
image: string;
|
||||
imageID: string;
|
||||
containerID: string;
|
||||
}
|
||||
|
||||
@autobind()
|
||||
export class Pod extends WorkloadKubeObject {
|
||||
static kind = "Pod"
|
||||
|
||||
spec: {
|
||||
volumes?: {
|
||||
name: string;
|
||||
persistentVolumeClaim: {
|
||||
claimName: string;
|
||||
};
|
||||
emptyDir: {
|
||||
medium?: string;
|
||||
sizeLimit?: string;
|
||||
};
|
||||
configMap: {
|
||||
name: string;
|
||||
}
|
||||
secret: {
|
||||
secretName: string;
|
||||
defaultMode: number;
|
||||
};
|
||||
}[];
|
||||
initContainers: IPodContainer[];
|
||||
containers: IPodContainer[];
|
||||
restartPolicy: string;
|
||||
terminationGracePeriodSeconds: number;
|
||||
dnsPolicy: string;
|
||||
serviceAccountName: string;
|
||||
serviceAccount: string;
|
||||
priority: number;
|
||||
priorityClassName: string;
|
||||
nodeName: string;
|
||||
nodeSelector?: {
|
||||
[selector: string]: string;
|
||||
};
|
||||
securityContext: {};
|
||||
schedulerName: string;
|
||||
tolerations: {
|
||||
key: string;
|
||||
operator: string;
|
||||
effect: string;
|
||||
tolerationSeconds: number;
|
||||
}[];
|
||||
affinity: IAffinity;
|
||||
}
|
||||
status: {
|
||||
phase: string;
|
||||
conditions: {
|
||||
type: string;
|
||||
status: string;
|
||||
lastProbeTime: number;
|
||||
lastTransitionTime: string;
|
||||
}[];
|
||||
hostIP: string;
|
||||
podIP: string;
|
||||
startTime: string;
|
||||
initContainerStatuses?: IPodContainerStatus[];
|
||||
containerStatuses?: IPodContainerStatus[];
|
||||
qosClass: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
getInitContainers() {
|
||||
return this.spec.initContainers || [];
|
||||
}
|
||||
|
||||
getContainers() {
|
||||
return this.spec.containers || [];
|
||||
}
|
||||
|
||||
getAllContainers() {
|
||||
return this.getContainers().concat(this.getInitContainers());
|
||||
}
|
||||
|
||||
getRunningContainers() {
|
||||
const statuses = this.getContainerStatuses()
|
||||
return this.getAllContainers().filter(container => {
|
||||
return statuses.find(status => status.name === container.name && !!status.state["running"])
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
getContainerStatuses(includeInitContainers = true) {
|
||||
const statuses: IPodContainerStatus[] = [];
|
||||
const { containerStatuses, initContainerStatuses } = this.status;
|
||||
if (containerStatuses) {
|
||||
statuses.push(...containerStatuses);
|
||||
}
|
||||
if (includeInitContainers && initContainerStatuses) {
|
||||
statuses.push(...initContainerStatuses);
|
||||
}
|
||||
return statuses;
|
||||
}
|
||||
|
||||
getRestartsCount(): number {
|
||||
const { containerStatuses } = this.status;
|
||||
if (!containerStatuses) return 0;
|
||||
return containerStatuses.reduce((count, item) => count + item.restartCount, 0);
|
||||
}
|
||||
|
||||
getQosClass() {
|
||||
return this.status.qosClass || "";
|
||||
}
|
||||
|
||||
getReason() {
|
||||
return this.status.reason || "";
|
||||
}
|
||||
|
||||
getPriorityClassName() {
|
||||
return this.spec.priorityClassName || "";
|
||||
}
|
||||
|
||||
// Returns one of 5 statuses: Running, Succeeded, Pending, Failed, Evicted
|
||||
getStatus() {
|
||||
const phase = this.getStatusPhase();
|
||||
const reason = this.getReason();
|
||||
const goodConditions = ["Initialized", "Ready"].every(condition =>
|
||||
!!this.getConditions().find(item => item.type === condition && item.status === "True")
|
||||
);
|
||||
if (reason === PodStatus.EVICTED) {
|
||||
return PodStatus.EVICTED;
|
||||
}
|
||||
if (phase === PodStatus.FAILED) {
|
||||
return PodStatus.FAILED;
|
||||
}
|
||||
if (phase === PodStatus.SUCCEEDED) {
|
||||
return PodStatus.SUCCEEDED;
|
||||
}
|
||||
if (phase === PodStatus.RUNNING && goodConditions) {
|
||||
return PodStatus.RUNNING;
|
||||
}
|
||||
return PodStatus.PENDING;
|
||||
}
|
||||
|
||||
// Returns pod phase or container error if occured
|
||||
getStatusMessage() {
|
||||
if (this.getReason() === PodStatus.EVICTED) return "Evicted";
|
||||
if (this.getStatus() === PodStatus.RUNNING && this.metadata.deletionTimestamp) return "Terminating";
|
||||
|
||||
let message = "";
|
||||
const statuses = this.getContainerStatuses(false); // not including initContainers
|
||||
if (statuses.length) {
|
||||
statuses.forEach(status => {
|
||||
const { state } = status;
|
||||
if (state.waiting) {
|
||||
const { reason } = state.waiting;
|
||||
message = reason ? reason : "Waiting";
|
||||
}
|
||||
if (state.terminated) {
|
||||
const { reason } = state.terminated;
|
||||
message = reason ? reason : "Terminated";
|
||||
}
|
||||
})
|
||||
}
|
||||
if (message) return message;
|
||||
return this.getStatusPhase();
|
||||
}
|
||||
|
||||
getStatusPhase() {
|
||||
return this.status.phase;
|
||||
}
|
||||
|
||||
getConditions() {
|
||||
return this.status.conditions || [];
|
||||
}
|
||||
|
||||
getVolumes() {
|
||||
return this.spec.volumes || [];
|
||||
}
|
||||
|
||||
getSecrets(): string[] {
|
||||
return this.getVolumes()
|
||||
.filter(vol => vol.secret)
|
||||
.map(vol => vol.secret.secretName);
|
||||
}
|
||||
|
||||
getNodeSelectors(): string[] {
|
||||
const { nodeSelector } = this.spec
|
||||
if (!nodeSelector) return []
|
||||
return Object.entries(nodeSelector).map(values => values.join(": "))
|
||||
}
|
||||
|
||||
getTolerations() {
|
||||
return this.spec.tolerations || []
|
||||
}
|
||||
|
||||
getAffinity(): IAffinity {
|
||||
return this.spec.affinity
|
||||
}
|
||||
|
||||
hasIssues() {
|
||||
const notReady = !!this.getConditions().find(condition => {
|
||||
return condition.type == "Ready" && condition.status !== "True"
|
||||
});
|
||||
const crashLoop = !!this.getContainerStatuses().find(condition => {
|
||||
const waiting = condition.state.waiting
|
||||
return (waiting && waiting.reason == "CrashLoopBackOff")
|
||||
})
|
||||
return (
|
||||
notReady ||
|
||||
crashLoop ||
|
||||
this.getStatusPhase() !== "Running"
|
||||
)
|
||||
}
|
||||
|
||||
getLivenessProbe(container: IPodContainer) {
|
||||
return this.getProbe(container.livenessProbe);
|
||||
}
|
||||
|
||||
getReadinessProbe(container: IPodContainer) {
|
||||
return this.getProbe(container.readinessProbe);
|
||||
}
|
||||
|
||||
getProbe(probeData: IContainerProbe) {
|
||||
if (!probeData) return [];
|
||||
const {
|
||||
httpGet, exec, tcpSocket, initialDelaySeconds, timeoutSeconds,
|
||||
periodSeconds, successThreshold, failureThreshold
|
||||
} = probeData;
|
||||
const probe = [];
|
||||
// HTTP Request
|
||||
if (httpGet) {
|
||||
const { path, port, host, scheme } = httpGet;
|
||||
probe.push(
|
||||
"http-get",
|
||||
`${scheme.toLowerCase()}://${host || ""}:${port || ""}${path || ""}`,
|
||||
);
|
||||
}
|
||||
// Command
|
||||
if (exec && exec.command) {
|
||||
probe.push(`exec [${exec.command.join(" ")}]`);
|
||||
}
|
||||
// TCP Probe
|
||||
if (tcpSocket && tcpSocket.port) {
|
||||
probe.push(`tcp-socket :${tcpSocket.port}`);
|
||||
}
|
||||
probe.push(
|
||||
`delay=${initialDelaySeconds || "0"}s`,
|
||||
`timeout=${timeoutSeconds || "0"}s`,
|
||||
`period=${periodSeconds || "0"}s`,
|
||||
`#success=${successThreshold || "0"}`,
|
||||
`#failure=${failureThreshold || "0"}`,
|
||||
);
|
||||
return probe;
|
||||
}
|
||||
|
||||
getNodeName() {
|
||||
return this.spec?.nodeName
|
||||
}
|
||||
|
||||
getSelectedNodeOs() {
|
||||
if (!this.spec.nodeSelector) return
|
||||
if (!this.spec.nodeSelector["kubernetes.io/os"] && !this.spec.nodeSelector["beta.kubernetes.io/os"]) return
|
||||
|
||||
return this.spec.nodeSelector["kubernetes.io/os"] || this.spec.nodeSelector["beta.kubernetes.io/os"]
|
||||
}
|
||||
}
|
||||
|
||||
export const podsApi = new PodsApi({
|
||||
kind: Pod.kind,
|
||||
apiBase: "/api/v1/pods",
|
||||
isNamespaced: true,
|
||||
objectConstructor: Pod,
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { autobind } from "../../utils";
|
||||
import { KubeObject } from "../kube-object";
|
||||
import { KubeApi } from "../kube-api";
|
||||
|
||||
@autobind()
|
||||
export class PodSecurityPolicy extends KubeObject {
|
||||
static kind = "PodSecurityPolicy"
|
||||
|
||||
spec: {
|
||||
allowPrivilegeEscalation?: boolean;
|
||||
allowedCSIDrivers?: {
|
||||
name: string;
|
||||
}[];
|
||||
allowedCapabilities: string[];
|
||||
allowedFlexVolumes?: {
|
||||
driver: string;
|
||||
}[];
|
||||
allowedHostPaths?: {
|
||||
pathPrefix: string;
|
||||
readOnly: boolean;
|
||||
}[];
|
||||
allowedProcMountTypes?: string[];
|
||||
allowedUnsafeSysctls?: string[];
|
||||
defaultAddCapabilities?: string[];
|
||||
defaultAllowPrivilegeEscalation?: boolean;
|
||||
forbiddenSysctls?: string[];
|
||||
fsGroup?: {
|
||||
rule: string;
|
||||
ranges: { max: number; min: number }[];
|
||||
};
|
||||
hostIPC?: boolean;
|
||||
hostNetwork?: boolean;
|
||||
hostPID?: boolean;
|
||||
hostPorts?: {
|
||||
max: number;
|
||||
min: number;
|
||||
}[];
|
||||
privileged?: boolean;
|
||||
readOnlyRootFilesystem?: boolean;
|
||||
requiredDropCapabilities?: string[];
|
||||
runAsGroup?: {
|
||||
ranges: { max: number; min: number }[];
|
||||
rule: string;
|
||||
};
|
||||
runAsUser?: {
|
||||
rule: string;
|
||||
ranges: { max: number; min: number }[];
|
||||
};
|
||||
runtimeClass?: {
|
||||
allowedRuntimeClassNames: string[];
|
||||
defaultRuntimeClassName: string;
|
||||
};
|
||||
seLinux?: {
|
||||
rule: string;
|
||||
seLinuxOptions: {
|
||||
level: string;
|
||||
role: string;
|
||||
type: string;
|
||||
user: string;
|
||||
};
|
||||
};
|
||||
supplementalGroups?: {
|
||||
rule: string;
|
||||
ranges: { max: number; min: number }[];
|
||||
};
|
||||
volumes?: string[];
|
||||
}
|
||||
|
||||
isPrivileged() {
|
||||
return !!this.spec.privileged;
|
||||
}
|
||||
|
||||
getVolumes() {
|
||||
return this.spec.volumes || [];
|
||||
}
|
||||
|
||||
getRules() {
|
||||
const { fsGroup, runAsGroup, runAsUser, supplementalGroups, seLinux } = this.spec;
|
||||
return {
|
||||
fsGroup: fsGroup ? fsGroup.rule : "",
|
||||
runAsGroup: runAsGroup ? runAsGroup.rule : "",
|
||||
runAsUser: runAsUser ? runAsUser.rule : "",
|
||||
supplementalGroups: supplementalGroups ? supplementalGroups.rule : "",
|
||||
seLinux: seLinux ? seLinux.rule : "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const pspApi = new KubeApi({
|
||||
kind: PodSecurityPolicy.kind,
|
||||
apiBase: "/apis/policy/v1beta1/podsecuritypolicies",
|
||||
isNamespaced: false,
|
||||
objectConstructor: PodSecurityPolicy,
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import get from "lodash/get";
|
||||
import { autobind } from "../../utils";
|
||||
import { IAffinity, WorkloadKubeObject } from "../workload-kube-object";
|
||||
import { IPodContainer } from "./pods.api";
|
||||
import { KubeApi } from "../kube-api";
|
||||
|
||||
@autobind()
|
||||
export class ReplicaSet extends WorkloadKubeObject {
|
||||
static kind = "ReplicaSet"
|
||||
|
||||
spec: {
|
||||
replicas?: number;
|
||||
selector?: {
|
||||
matchLabels: {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
containers?: IPodContainer[];
|
||||
template?: {
|
||||
spec?: {
|
||||
affinity?: IAffinity;
|
||||
nodeSelector?: {
|
||||
[selector: string]: string;
|
||||
};
|
||||
tolerations: {
|
||||
key: string;
|
||||
operator: string;
|
||||
effect: string;
|
||||
tolerationSeconds: number;
|
||||
}[];
|
||||
containers: IPodContainer[];
|
||||
};
|
||||
};
|
||||
restartPolicy?: string;
|
||||
terminationGracePeriodSeconds?: number;
|
||||
dnsPolicy?: string;
|
||||
schedulerName?: string;
|
||||
}
|
||||
status: {
|
||||
replicas: number;
|
||||
fullyLabeledReplicas: number;
|
||||
readyReplicas: number;
|
||||
availableReplicas: number;
|
||||
observedGeneration: number;
|
||||
}
|
||||
|
||||
getImages() {
|
||||
const containers: IPodContainer[] = get(this, "spec.template.spec.containers", [])
|
||||
return [...containers].map(container => container.image)
|
||||
}
|
||||
}
|
||||
|
||||
export const replicaSetApi = new KubeApi({
|
||||
kind: ReplicaSet.kind,
|
||||
apiBase: "/apis/apps/v1/replicasets",
|
||||
isNamespaced: true,
|
||||
objectConstructor: ReplicaSet,
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import jsYaml from "js-yaml"
|
||||
import { KubeObject } from "../kube-object";
|
||||
import { KubeJsonApiData } from "../kube-json-api";
|
||||
import { apiKubeResourceApplier } from "../index";
|
||||
import { apiManager } from "../api-manager";
|
||||
|
||||
export const resourceApplierApi = {
|
||||
annotations: [
|
||||
"kubectl.kubernetes.io/last-applied-configuration"
|
||||
],
|
||||
|
||||
async update<D extends KubeObject>(resource: object | string): Promise<D> {
|
||||
if (typeof resource === "string") {
|
||||
resource = jsYaml.safeLoad(resource);
|
||||
}
|
||||
return apiKubeResourceApplier
|
||||
.post<KubeJsonApiData[]>("/stack", { data: resource })
|
||||
.then(data => {
|
||||
const items = data.map(obj => {
|
||||
const api = apiManager.getApi(obj.metadata.selfLink);
|
||||
return new api.objectConstructor(obj);
|
||||
});
|
||||
return items.length === 1 ? items[0] : items;
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { KubeObject } from "../kube-object";
|
||||
import { KubeApi } from "../kube-api";
|
||||
import { KubeJsonApiData } from "../kube-json-api";
|
||||
|
||||
export interface IResourceQuotaValues {
|
||||
[quota: string]: string;
|
||||
|
||||
// Compute Resource Quota
|
||||
"limits.cpu"?: string;
|
||||
"limits.memory"?: string;
|
||||
"requests.cpu"?: string;
|
||||
"requests.memory"?: string;
|
||||
|
||||
// Storage Resource Quota
|
||||
"requests.storage"?: string;
|
||||
"persistentvolumeclaims"?: string;
|
||||
|
||||
// Object Count Quota
|
||||
"count/pods"?: string;
|
||||
"count/persistentvolumeclaims"?: string;
|
||||
"count/services"?: string;
|
||||
"count/secrets"?: string;
|
||||
"count/configmaps"?: string;
|
||||
"count/replicationcontrollers"?: string;
|
||||
"count/deployments.apps"?: string;
|
||||
"count/replicasets.apps"?: string;
|
||||
"count/statefulsets.apps"?: string;
|
||||
"count/jobs.batch"?: string;
|
||||
"count/cronjobs.batch"?: string;
|
||||
"count/deployments.extensions"?: string;
|
||||
}
|
||||
|
||||
export class ResourceQuota extends KubeObject {
|
||||
static kind = "ResourceQuota"
|
||||
|
||||
constructor(data: KubeJsonApiData) {
|
||||
super(data);
|
||||
this.spec = this.spec || {} as any
|
||||
}
|
||||
|
||||
spec: {
|
||||
hard: IResourceQuotaValues;
|
||||
scopeSelector?: {
|
||||
matchExpressions: {
|
||||
operator: string;
|
||||
scopeName: string;
|
||||
values: string[];
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
status: {
|
||||
hard: IResourceQuotaValues;
|
||||
used: IResourceQuotaValues;
|
||||
}
|
||||
|
||||
getScopeSelector() {
|
||||
const { matchExpressions = [] } = this.spec.scopeSelector || {};
|
||||
return matchExpressions;
|
||||
}
|
||||
}
|
||||
|
||||
export const resourceQuotaApi = new KubeApi({
|
||||
kind: ResourceQuota.kind,
|
||||
apiBase: "/api/v1/resourcequotas",
|
||||
isNamespaced: true,
|
||||
objectConstructor: ResourceQuota,
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { autobind } from "../../utils";
|
||||
import { KubeObject } from "../kube-object";
|
||||
import { KubeApi } from "../kube-api";
|
||||
|
||||
export interface IRoleBindingSubject {
|
||||
kind: string;
|
||||
name: string;
|
||||
namespace?: string;
|
||||
apiGroup?: string;
|
||||
}
|
||||
|
||||
@autobind()
|
||||
export class RoleBinding extends KubeObject {
|
||||
static kind = "RoleBinding"
|
||||
|
||||
subjects?: IRoleBindingSubject[]
|
||||
roleRef: {
|
||||
kind: string;
|
||||
name: string;
|
||||
apiGroup?: string;
|
||||
}
|
||||
|
||||
getSubjects() {
|
||||
return this.subjects || [];
|
||||
}
|
||||
|
||||
getSubjectNames(): string {
|
||||
return this.getSubjects().map(subject => subject.name).join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
export const roleBindingApi = new KubeApi({
|
||||
kind: RoleBinding.kind,
|
||||
apiBase: "/apis/rbac.authorization.k8s.io/v1/rolebindings",
|
||||
isNamespaced: true,
|
||||
objectConstructor: RoleBinding,
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { KubeObject } from "../kube-object";
|
||||
import { KubeApi } from "../kube-api";
|
||||
|
||||
export class Role extends KubeObject {
|
||||
static kind = "Role"
|
||||
|
||||
rules: {
|
||||
verbs: string[];
|
||||
apiGroups: string[];
|
||||
resources: string[];
|
||||
resourceNames?: string[];
|
||||
}[]
|
||||
|
||||
getRules() {
|
||||
return this.rules || [];
|
||||
}
|
||||
}
|
||||
|
||||
export const roleApi = new KubeApi({
|
||||
kind: Role.kind,
|
||||
apiBase: "/apis/rbac.authorization.k8s.io/v1/roles",
|
||||
isNamespaced: true,
|
||||
objectConstructor: Role,
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { KubeObject } from "../kube-object";
|
||||
import { KubeJsonApiData } from "../kube-json-api";
|
||||
import { autobind } from "../../utils";
|
||||
import { KubeApi } from "../kube-api";
|
||||
|
||||
export enum SecretType {
|
||||
Opaque = "Opaque",
|
||||
ServiceAccountToken = "kubernetes.io/service-account-token",
|
||||
Dockercfg = "kubernetes.io/dockercfg",
|
||||
DockerConfigJson = "kubernetes.io/dockerconfigjson",
|
||||
BasicAuth = "kubernetes.io/basic-auth",
|
||||
SSHAuth = "kubernetes.io/ssh-auth",
|
||||
TLS = "kubernetes.io/tls",
|
||||
BootstrapToken = "bootstrap.kubernetes.io/token",
|
||||
}
|
||||
|
||||
export interface ISecretRef {
|
||||
key?: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
@autobind()
|
||||
export class Secret extends KubeObject {
|
||||
static kind = "Secret"
|
||||
|
||||
type: SecretType;
|
||||
data: {
|
||||
[prop: string]: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
constructor(data: KubeJsonApiData) {
|
||||
super(data);
|
||||
this.data = this.data || {};
|
||||
}
|
||||
|
||||
getKeys(): string[] {
|
||||
return Object.keys(this.data);
|
||||
}
|
||||
|
||||
getToken() {
|
||||
return this.data.token;
|
||||
}
|
||||
}
|
||||
|
||||
export const secretsApi = new KubeApi({
|
||||
kind: Secret.kind,
|
||||
apiBase: "/api/v1/secrets",
|
||||
isNamespaced: true,
|
||||
objectConstructor: Secret,
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { KubeObject } from "../kube-object";
|
||||
import { KubeApi } from "../kube-api";
|
||||
|
||||
export class SelfSubjectRulesReviewApi extends KubeApi<SelfSubjectRulesReview> {
|
||||
create({ namespace = "default" }): Promise<SelfSubjectRulesReview> {
|
||||
return super.create({}, {
|
||||
spec: {
|
||||
namespace
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ISelfSubjectReviewRule {
|
||||
verbs: string[];
|
||||
apiGroups?: string[];
|
||||
resources?: string[];
|
||||
resourceNames?: string[];
|
||||
nonResourceURLs?: string[];
|
||||
}
|
||||
|
||||
export class SelfSubjectRulesReview extends KubeObject {
|
||||
static kind = "SelfSubjectRulesReview"
|
||||
|
||||
spec: {
|
||||
// fixme: add more types from api docs
|
||||
namespace?: string;
|
||||
}
|
||||
|
||||
status: {
|
||||
resourceRules: ISelfSubjectReviewRule[];
|
||||
nonResourceRules: ISelfSubjectReviewRule[];
|
||||
incomplete: boolean;
|
||||
}
|
||||
|
||||
getResourceRules() {
|
||||
const rules = this.status && this.status.resourceRules || [];
|
||||
return rules.map(rule => this.normalize(rule));
|
||||
}
|
||||
|
||||
getNonResourceRules() {
|
||||
const rules = this.status && this.status.nonResourceRules || [];
|
||||
return rules.map(rule => this.normalize(rule));
|
||||
}
|
||||
|
||||
protected normalize(rule: ISelfSubjectReviewRule): ISelfSubjectReviewRule {
|
||||
const { apiGroups = [], resourceNames = [], verbs = [], nonResourceURLs = [], resources = [] } = rule;
|
||||
return {
|
||||
apiGroups,
|
||||
nonResourceURLs,
|
||||
resourceNames,
|
||||
verbs,
|
||||
resources: resources.map((resource, index) => {
|
||||
const apiGroup = apiGroups.length >= index + 1 ? apiGroups[index] : apiGroups.slice(-1)[0];
|
||||
const separator = apiGroup == "" ? "" : ".";
|
||||
return resource + separator + apiGroup;
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const selfSubjectRulesReviewApi = new SelfSubjectRulesReviewApi({
|
||||
kind: SelfSubjectRulesReview.kind,
|
||||
apiBase: "/apis/authorization.k8s.io/v1/selfsubjectrulesreviews",
|
||||
isNamespaced: false,
|
||||
objectConstructor: SelfSubjectRulesReview,
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { autobind } from "../../utils";
|
||||
import { KubeObject } from "../kube-object";
|
||||
import { KubeApi } from "../kube-api";
|
||||
|
||||
@autobind()
|
||||
export class ServiceAccount extends KubeObject {
|
||||
static kind = "ServiceAccount";
|
||||
|
||||
secrets?: {
|
||||
name: string;
|
||||
}[]
|
||||
imagePullSecrets?: {
|
||||
name: string;
|
||||
}[]
|
||||
|
||||
getSecrets() {
|
||||
return this.secrets || [];
|
||||
}
|
||||
|
||||
getImagePullSecrets() {
|
||||
return this.imagePullSecrets || [];
|
||||
}
|
||||
}
|
||||
|
||||
export const serviceAccountsApi = new KubeApi<ServiceAccount>({
|
||||
kind: ServiceAccount.kind,
|
||||
apiBase: "/api/v1/serviceaccounts",
|
||||
isNamespaced: true,
|
||||
objectConstructor: ServiceAccount,
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { autobind } from "../../utils";
|
||||
import { KubeObject } from "../kube-object";
|
||||
import { KubeApi } from "../kube-api";
|
||||
|
||||
export interface IServicePort {
|
||||
name?: string;
|
||||
protocol: string;
|
||||
port: number;
|
||||
targetPort: number;
|
||||
}
|
||||
|
||||
export class ServicePort implements IServicePort {
|
||||
name?: string;
|
||||
protocol: string;
|
||||
port: number;
|
||||
targetPort: number;
|
||||
nodePort?: number;
|
||||
|
||||
constructor(data: IServicePort) {
|
||||
Object.assign(this, data)
|
||||
}
|
||||
|
||||
toString() {
|
||||
if (this.nodePort) {
|
||||
return `${this.port}:${this.nodePort}/${this.protocol}`;
|
||||
} else {
|
||||
return `${this.port}${this.port === this.targetPort ? "" : ":" + this.targetPort}/${this.protocol}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@autobind()
|
||||
export class Service extends KubeObject {
|
||||
static kind = "Service"
|
||||
|
||||
spec: {
|
||||
type: string;
|
||||
clusterIP: string;
|
||||
externalTrafficPolicy?: string;
|
||||
loadBalancerIP?: string;
|
||||
sessionAffinity: string;
|
||||
selector: { [key: string]: string };
|
||||
ports: ServicePort[];
|
||||
externalIPs?: string[]; // https://kubernetes.io/docs/concepts/services-networking/service/#external-ips
|
||||
}
|
||||
|
||||
status: {
|
||||
loadBalancer?: {
|
||||
ingress?: {
|
||||
ip?: string;
|
||||
hostname?: string;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
getClusterIp() {
|
||||
return this.spec.clusterIP;
|
||||
}
|
||||
|
||||
getExternalIps() {
|
||||
const lb = this.getLoadBalancer();
|
||||
if (lb && lb.ingress) {
|
||||
return lb.ingress.map(val => val.ip || val.hostname)
|
||||
}
|
||||
return this.spec.externalIPs || [];
|
||||
}
|
||||
|
||||
getType() {
|
||||
return this.spec.type || "-";
|
||||
}
|
||||
|
||||
getSelector(): string[] {
|
||||
if (!this.spec.selector) return [];
|
||||
return Object.entries(this.spec.selector).map(val => val.join("="));
|
||||
}
|
||||
|
||||
getPorts(): ServicePort[] {
|
||||
const ports = this.spec.ports || [];
|
||||
return ports.map(p => new ServicePort(p));
|
||||
}
|
||||
|
||||
getLoadBalancer() {
|
||||
return this.status.loadBalancer;
|
||||
}
|
||||
|
||||
isActive() {
|
||||
return this.getType() !== "LoadBalancer" || this.getExternalIps().length > 0;
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return this.isActive() ? "Active" : "Pending";
|
||||
}
|
||||
}
|
||||
|
||||
export const serviceApi = new KubeApi({
|
||||
kind: Service.kind,
|
||||
apiBase: "/api/v1/services",
|
||||
isNamespaced: true,
|
||||
objectConstructor: Service,
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import get from "lodash/get";
|
||||
import { IPodContainer } from "./pods.api";
|
||||
import { IAffinity, WorkloadKubeObject } from "../workload-kube-object";
|
||||
import { autobind } from "../../utils";
|
||||
import { KubeApi } from "../kube-api";
|
||||
|
||||
@autobind()
|
||||
export class StatefulSet extends WorkloadKubeObject {
|
||||
static kind = "StatefulSet"
|
||||
|
||||
spec: {
|
||||
serviceName: string;
|
||||
replicas: number;
|
||||
selector: {
|
||||
matchLabels: {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
template: {
|
||||
metadata: {
|
||||
labels: {
|
||||
app: string;
|
||||
};
|
||||
};
|
||||
spec: {
|
||||
containers: {
|
||||
name: string;
|
||||
image: string;
|
||||
ports: {
|
||||
containerPort: number;
|
||||
name: string;
|
||||
}[];
|
||||
volumeMounts: {
|
||||
name: string;
|
||||
mountPath: string;
|
||||
}[];
|
||||
}[];
|
||||
affinity?: IAffinity;
|
||||
nodeSelector?: {
|
||||
[selector: string]: string;
|
||||
};
|
||||
tolerations: {
|
||||
key: string;
|
||||
operator: string;
|
||||
effect: string;
|
||||
tolerationSeconds: number;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
volumeClaimTemplates: {
|
||||
metadata: {
|
||||
name: string;
|
||||
};
|
||||
spec: {
|
||||
accessModes: string[];
|
||||
resources: {
|
||||
requests: {
|
||||
storage: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
}[];
|
||||
}
|
||||
status: {
|
||||
observedGeneration: number;
|
||||
replicas: number;
|
||||
currentReplicas: number;
|
||||
currentRevision: string;
|
||||
updateRevision: string;
|
||||
collisionCount: number;
|
||||
}
|
||||
|
||||
getImages() {
|
||||
const containers: IPodContainer[] = get(this, "spec.template.spec.containers", [])
|
||||
return [...containers].map(container => container.image)
|
||||
}
|
||||
}
|
||||
|
||||
export const statefulSetApi = new KubeApi({
|
||||
kind: StatefulSet.kind,
|
||||
apiBase: "/apis/apps/v1/statefulsets",
|
||||
isNamespaced: true,
|
||||
objectConstructor: StatefulSet,
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { autobind } from "../../utils";
|
||||
import { KubeObject } from "../kube-object";
|
||||
import { KubeApi } from "../kube-api";
|
||||
|
||||
@autobind()
|
||||
export class StorageClass extends KubeObject {
|
||||
static kind = "StorageClass"
|
||||
|
||||
provisioner: string; // e.g. "storage.k8s.io/v1"
|
||||
mountOptions?: string[];
|
||||
volumeBindingMode: string;
|
||||
reclaimPolicy: string;
|
||||
parameters: {
|
||||
[param: string]: string; // every provisioner has own set of these parameters
|
||||
}
|
||||
|
||||
isDefault() {
|
||||
const annotations = this.metadata.annotations || {};
|
||||
return (
|
||||
annotations["storageclass.kubernetes.io/is-default-class"] === "true" ||
|
||||
annotations["storageclass.beta.kubernetes.io/is-default-class"] === "true"
|
||||
)
|
||||
}
|
||||
|
||||
getVolumeBindingMode() {
|
||||
return this.volumeBindingMode || "-"
|
||||
}
|
||||
|
||||
getReclaimPolicy() {
|
||||
return this.reclaimPolicy || "-"
|
||||
}
|
||||
}
|
||||
|
||||
export const storageClassApi = new KubeApi({
|
||||
kind: StorageClass.kind,
|
||||
apiBase: "/apis/storage.k8s.io/v1/storageclasses",
|
||||
isNamespaced: false,
|
||||
objectConstructor: StorageClass,
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { JsonApi, JsonApiErrorParsed } from "./json-api";
|
||||
import { KubeJsonApi } from "./kube-json-api";
|
||||
import { Notifications } from "../components/notifications";
|
||||
import { apiPrefix, isDevelopment } from "../../common/vars";
|
||||
|
||||
//-- JSON HTTP APIS
|
||||
|
||||
export const apiBase = new JsonApi({
|
||||
debug: isDevelopment,
|
||||
apiPrefix: apiPrefix.BASE,
|
||||
});
|
||||
export const apiKube = new KubeJsonApi({
|
||||
debug: isDevelopment,
|
||||
apiPrefix: apiPrefix.KUBE_BASE,
|
||||
});
|
||||
export const apiKubeUsers = new KubeJsonApi({
|
||||
debug: isDevelopment,
|
||||
apiPrefix: apiPrefix.KUBE_USERS,
|
||||
});
|
||||
export const apiKubeHelm = new KubeJsonApi({
|
||||
debug: isDevelopment,
|
||||
apiPrefix: apiPrefix.KUBE_HELM,
|
||||
});
|
||||
export const apiKubeResourceApplier = new KubeJsonApi({
|
||||
debug: isDevelopment,
|
||||
apiPrefix: apiPrefix.KUBE_RESOURCE_APPLIER,
|
||||
});
|
||||
|
||||
// Common handler for HTTP api errors
|
||||
function onApiError(error: JsonApiErrorParsed, res: Response) {
|
||||
switch (res.status) {
|
||||
case 403:
|
||||
error.isUsedForNotification = true;
|
||||
Notifications.error(error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
apiBase.onError.addListener(onApiError);
|
||||
apiKube.onError.addListener(onApiError);
|
||||
apiKubeUsers.onError.addListener(onApiError);
|
||||
apiKubeHelm.onError.addListener(onApiError);
|
||||
apiKubeResourceApplier.onError.addListener(onApiError);
|
||||
@@ -0,0 +1,155 @@
|
||||
// Base http-service / json-api class
|
||||
|
||||
import { stringify } from "querystring";
|
||||
import { EventEmitter } from "../utils/eventEmitter";
|
||||
import { cancelableFetch } from "../utils/cancelableFetch";
|
||||
|
||||
export interface JsonApiData {
|
||||
}
|
||||
|
||||
export interface JsonApiError {
|
||||
code?: number;
|
||||
message?: string;
|
||||
errors?: { id: string; title: string; status?: number }[];
|
||||
}
|
||||
|
||||
export interface JsonApiParams<D = any> {
|
||||
query?: { [param: string]: string | number | any };
|
||||
data?: D; // request body
|
||||
}
|
||||
|
||||
export interface JsonApiLog {
|
||||
method: string;
|
||||
reqUrl: string;
|
||||
reqInit: RequestInit;
|
||||
data?: any;
|
||||
error?: any;
|
||||
}
|
||||
|
||||
export interface JsonApiConfig {
|
||||
apiPrefix: string;
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
export class JsonApi<D = JsonApiData, P extends JsonApiParams = JsonApiParams> {
|
||||
static reqInitDefault: RequestInit = {
|
||||
headers: {
|
||||
'content-type': 'application/json'
|
||||
}
|
||||
};
|
||||
|
||||
static configDefault: Partial<JsonApiConfig> = {
|
||||
debug: false
|
||||
};
|
||||
|
||||
constructor(protected config: JsonApiConfig, protected reqInit?: RequestInit) {
|
||||
this.config = Object.assign({}, JsonApi.configDefault, config);
|
||||
this.reqInit = Object.assign({}, JsonApi.reqInitDefault, reqInit);
|
||||
this.parseResponse = this.parseResponse.bind(this);
|
||||
}
|
||||
|
||||
public onData = new EventEmitter<[D, Response]>();
|
||||
public onError = new EventEmitter<[JsonApiErrorParsed, Response]>();
|
||||
|
||||
get<T = D>(path: string, params?: P, reqInit: RequestInit = {}) {
|
||||
return this.request<T>(path, params, { ...reqInit, method: "get" });
|
||||
}
|
||||
|
||||
post<T = D>(path: string, params?: P, reqInit: RequestInit = {}) {
|
||||
return this.request<T>(path, params, { ...reqInit, method: "post" });
|
||||
}
|
||||
|
||||
put<T = D>(path: string, params?: P, reqInit: RequestInit = {}) {
|
||||
return this.request<T>(path, params, { ...reqInit, method: "put" });
|
||||
}
|
||||
|
||||
patch<T = D>(path: string, params?: P, reqInit: RequestInit = {}) {
|
||||
return this.request<T>(path, params, { ...reqInit, method: "patch" });
|
||||
}
|
||||
|
||||
del<T = D>(path: string, params?: P, reqInit: RequestInit = {}) {
|
||||
return this.request<T>(path, params, { ...reqInit, method: "delete" });
|
||||
}
|
||||
|
||||
protected request<D>(path: string, params?: P, init: RequestInit = {}) {
|
||||
let reqUrl = this.config.apiPrefix + path;
|
||||
const reqInit: RequestInit = { ...this.reqInit, ...init };
|
||||
const { data, query } = params || {} as P;
|
||||
if (data && !reqInit.body) {
|
||||
reqInit.body = JSON.stringify(data);
|
||||
}
|
||||
if (query) {
|
||||
const queryString = stringify(query);
|
||||
reqUrl += (reqUrl.includes("?") ? "&" : "?") + queryString;
|
||||
}
|
||||
const infoLog: JsonApiLog = {
|
||||
method: reqInit.method.toUpperCase(),
|
||||
reqUrl: reqUrl,
|
||||
reqInit: reqInit,
|
||||
};
|
||||
return cancelableFetch(reqUrl, reqInit).then(res => {
|
||||
return this.parseResponse<D>(res, infoLog);
|
||||
});
|
||||
}
|
||||
|
||||
protected parseResponse<D>(res: Response, log: JsonApiLog): Promise<D> {
|
||||
const { status } = res;
|
||||
return res.text().then(text => {
|
||||
let data;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : ""; // DELETE-requests might not have response-body
|
||||
} catch (e) {
|
||||
data = text;
|
||||
}
|
||||
if (status >= 200 && status < 300) {
|
||||
this.onData.emit(data, res);
|
||||
this.writeLog({ ...log, data });
|
||||
return data;
|
||||
} else if (log.method === "GET" && res.status === 403) {
|
||||
this.writeLog({ ...log, data });
|
||||
} else {
|
||||
const error = new JsonApiErrorParsed(data, this.parseError(data, res));
|
||||
this.onError.emit(error, res);
|
||||
this.writeLog({ ...log, error })
|
||||
throw error;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
protected parseError(error: JsonApiError | string, res: Response): string[] {
|
||||
if (typeof error === "string") {
|
||||
return [error]
|
||||
}
|
||||
else if (Array.isArray(error.errors)) {
|
||||
return error.errors.map(error => error.title)
|
||||
}
|
||||
else if (error.message) {
|
||||
return [error.message]
|
||||
}
|
||||
return [res.statusText || "Error!"]
|
||||
}
|
||||
|
||||
protected writeLog(log: JsonApiLog) {
|
||||
if (!this.config.debug) return;
|
||||
const { method, reqUrl, ...params } = log;
|
||||
let textStyle = 'font-weight: bold;';
|
||||
if (params.data) textStyle += 'background: green; color: white;';
|
||||
if (params.error) textStyle += 'background: red; color: white;';
|
||||
console.log(`%c${method} ${reqUrl}`, textStyle, params);
|
||||
}
|
||||
}
|
||||
|
||||
export class JsonApiErrorParsed {
|
||||
isUsedForNotification = false;
|
||||
|
||||
constructor(private error: JsonApiError | DOMException, private messages: string[]) {
|
||||
}
|
||||
|
||||
get isAborted() {
|
||||
return this.error.code === DOMException.ABORT_ERR;
|
||||
}
|
||||
|
||||
toString() {
|
||||
return this.messages.join("\n");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// Base class for building all kubernetes apis
|
||||
|
||||
import merge from "lodash/merge"
|
||||
import { stringify } from "querystring";
|
||||
import { IKubeObjectConstructor, KubeObject } from "./kube-object";
|
||||
import { IKubeObjectRef, KubeJsonApi, KubeJsonApiData, KubeJsonApiDataList } from "./kube-json-api";
|
||||
import { apiKube } from "./index";
|
||||
import { kubeWatchApi } from "./kube-watch-api";
|
||||
import { apiManager } from "./api-manager";
|
||||
|
||||
export interface IKubeApiOptions<T extends KubeObject> {
|
||||
kind: string; // resource type within api-group, e.g. "Namespace"
|
||||
apiBase: string; // base api-path for listing all resources, e.g. "/api/v1/pods"
|
||||
isNamespaced: boolean;
|
||||
objectConstructor?: IKubeObjectConstructor<T>;
|
||||
request?: KubeJsonApi;
|
||||
}
|
||||
|
||||
export interface IKubeApiQueryParams {
|
||||
watch?: boolean | number;
|
||||
resourceVersion?: string;
|
||||
timeoutSeconds?: number;
|
||||
limit?: number; // doesn't work with ?watch
|
||||
continue?: string; // might be used with ?limit from second request
|
||||
}
|
||||
|
||||
export interface IKubeApiLinkRef {
|
||||
apiPrefix?: string;
|
||||
apiVersion: string;
|
||||
resource: string;
|
||||
name: string;
|
||||
namespace?: string;
|
||||
}
|
||||
|
||||
export class KubeApi<T extends KubeObject = any> {
|
||||
static matcher = /(\/apis?.*?)\/(?:(.*?)\/)?(v.*?)(?:\/namespaces\/(.+?))?\/([^\/]+)(?:\/([^\/?]+))?.*$/
|
||||
|
||||
static parseApi(apiPath = "") {
|
||||
apiPath = new URL(apiPath, location.origin).pathname;
|
||||
const [, apiPrefix, apiGroup = "", apiVersion, namespace, resource, name] = apiPath.match(KubeApi.matcher) || [];
|
||||
const apiVersionWithGroup = [apiGroup, apiVersion].filter(v => v).join("/");
|
||||
const apiBase = [apiPrefix, apiGroup, apiVersion, resource].filter(v => v).join("/");
|
||||
return {
|
||||
apiBase,
|
||||
apiPrefix, apiGroup,
|
||||
apiVersion, apiVersionWithGroup,
|
||||
namespace, resource, name,
|
||||
}
|
||||
}
|
||||
|
||||
static createLink(ref: IKubeApiLinkRef): string {
|
||||
const { apiPrefix = "/apis", resource, apiVersion, name } = ref;
|
||||
let { namespace } = ref;
|
||||
if (namespace) {
|
||||
namespace = `namespaces/${namespace}`
|
||||
}
|
||||
return [apiPrefix, apiVersion, namespace, resource, name]
|
||||
.filter(v => !!v)
|
||||
.join("/")
|
||||
}
|
||||
|
||||
static watchAll(...apis: KubeApi[]) {
|
||||
const disposers = apis.map(api => api.watch());
|
||||
return () => disposers.forEach(unwatch => unwatch());
|
||||
}
|
||||
|
||||
readonly kind: string
|
||||
readonly apiBase: string
|
||||
readonly apiPrefix: string
|
||||
readonly apiGroup: string
|
||||
readonly apiVersion: string
|
||||
readonly apiVersionWithGroup: string
|
||||
readonly apiResource: string
|
||||
readonly isNamespaced: boolean
|
||||
|
||||
public objectConstructor: IKubeObjectConstructor<T>;
|
||||
protected request: KubeJsonApi;
|
||||
protected resourceVersions = new Map<string, string>();
|
||||
|
||||
constructor(protected options: IKubeApiOptions<T>) {
|
||||
const {
|
||||
kind,
|
||||
isNamespaced = false,
|
||||
objectConstructor = KubeObject as IKubeObjectConstructor,
|
||||
request = apiKube
|
||||
} = options || {};
|
||||
const { apiBase, apiPrefix, apiGroup, apiVersion, apiVersionWithGroup, resource } = KubeApi.parseApi(options.apiBase);
|
||||
|
||||
this.kind = kind;
|
||||
this.isNamespaced = isNamespaced;
|
||||
this.apiBase = apiBase;
|
||||
this.apiPrefix = apiPrefix;
|
||||
this.apiGroup = apiGroup;
|
||||
this.apiVersion = apiVersion;
|
||||
this.apiVersionWithGroup = apiVersionWithGroup;
|
||||
this.apiResource = resource;
|
||||
this.request = request;
|
||||
this.objectConstructor = objectConstructor;
|
||||
|
||||
this.parseResponse = this.parseResponse.bind(this);
|
||||
apiManager.registerApi(apiBase, this);
|
||||
}
|
||||
|
||||
setResourceVersion(namespace = "", newVersion: string) {
|
||||
this.resourceVersions.set(namespace, newVersion);
|
||||
}
|
||||
|
||||
getResourceVersion(namespace = "") {
|
||||
return this.resourceVersions.get(namespace);
|
||||
}
|
||||
|
||||
async refreshResourceVersion(params?: { namespace: string }) {
|
||||
return this.list(params, { limit: 1 });
|
||||
}
|
||||
|
||||
getUrl({ name = "", namespace = "" } = {}, query?: Partial<IKubeApiQueryParams>) {
|
||||
const { apiPrefix, apiVersionWithGroup, apiResource } = this;
|
||||
const resourcePath = KubeApi.createLink({
|
||||
apiPrefix: apiPrefix,
|
||||
apiVersion: apiVersionWithGroup,
|
||||
resource: apiResource,
|
||||
namespace: this.isNamespaced ? namespace : undefined,
|
||||
name: name,
|
||||
});
|
||||
return resourcePath + (query ? `?` + stringify(query) : "");
|
||||
}
|
||||
|
||||
protected parseResponse(data: KubeJsonApiData | KubeJsonApiData[] | KubeJsonApiDataList, namespace?: string): any {
|
||||
const KubeObjectConstructor = this.objectConstructor;
|
||||
if (KubeObject.isJsonApiData(data)) {
|
||||
return new KubeObjectConstructor(data);
|
||||
}
|
||||
// process items list response
|
||||
else if (KubeObject.isJsonApiDataList(data)) {
|
||||
const { apiVersion, items, metadata } = data;
|
||||
this.setResourceVersion(namespace, metadata.resourceVersion);
|
||||
this.setResourceVersion("", metadata.resourceVersion);
|
||||
return items.map(item => new KubeObjectConstructor({
|
||||
kind: this.kind,
|
||||
apiVersion: apiVersion,
|
||||
...item,
|
||||
}))
|
||||
}
|
||||
// custom apis might return array for list response, e.g. users, groups, etc.
|
||||
else if (Array.isArray(data)) {
|
||||
return data.map(data => new KubeObjectConstructor(data));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async list({ namespace = "" } = {}, query?: IKubeApiQueryParams): Promise<T[]> {
|
||||
return this.request
|
||||
.get(this.getUrl({ namespace }), { query })
|
||||
.then(data => this.parseResponse(data, namespace));
|
||||
}
|
||||
|
||||
async get({ name = "", namespace = "default" } = {}, query?: IKubeApiQueryParams): Promise<T> {
|
||||
return this.request
|
||||
.get(this.getUrl({ namespace, name }), { query })
|
||||
.then(this.parseResponse);
|
||||
}
|
||||
|
||||
async create({ name = "", namespace = "default" } = {}, data?: Partial<T>): Promise<T> {
|
||||
const apiUrl = this.getUrl({ namespace });
|
||||
return this.request.post(apiUrl, {
|
||||
data: merge({
|
||||
kind: this.kind,
|
||||
apiVersion: this.apiVersionWithGroup,
|
||||
metadata: {
|
||||
name,
|
||||
namespace
|
||||
}
|
||||
}, data)
|
||||
}).then(this.parseResponse);
|
||||
}
|
||||
|
||||
async update({ name = "", namespace = "default" } = {}, data?: Partial<T>): Promise<T> {
|
||||
const apiUrl = this.getUrl({ namespace, name });
|
||||
return this.request
|
||||
.put(apiUrl, { data })
|
||||
.then(this.parseResponse)
|
||||
}
|
||||
|
||||
async delete({ name = "", namespace = "default" }) {
|
||||
const apiUrl = this.getUrl({ namespace, name });
|
||||
return this.request.del(apiUrl)
|
||||
}
|
||||
|
||||
getWatchUrl(namespace = "", query: IKubeApiQueryParams = {}) {
|
||||
return this.getUrl({ namespace }, {
|
||||
watch: 1,
|
||||
resourceVersion: this.getResourceVersion(namespace),
|
||||
...query,
|
||||
})
|
||||
}
|
||||
|
||||
watch(): () => void {
|
||||
return kubeWatchApi.subscribe(this);
|
||||
}
|
||||
}
|
||||
|
||||
export function lookupApiLink(ref: IKubeObjectRef, parentObject: KubeObject): string {
|
||||
const {
|
||||
kind, apiVersion, name,
|
||||
namespace = parentObject.getNs()
|
||||
} = ref;
|
||||
|
||||
// search in registered apis by 'kind' & 'apiVersion'
|
||||
const api = apiManager.getApi(api => api.kind === kind && api.apiVersionWithGroup == apiVersion)
|
||||
if (api) {
|
||||
return api.getUrl({ namespace, name })
|
||||
}
|
||||
|
||||
// lookup api by generated resource link
|
||||
const apiPrefixes = ["/apis", "/api"];
|
||||
const resource = kind.toLowerCase() + kind.endsWith("s") ? "es" : "s";
|
||||
for (const apiPrefix of apiPrefixes) {
|
||||
const apiLink = KubeApi.createLink({ apiPrefix, apiVersion, name, namespace, resource });
|
||||
if (apiManager.getApi(apiLink)) {
|
||||
return apiLink;
|
||||
}
|
||||
}
|
||||
|
||||
// resolve by kind only (hpa's might use refs to older versions of resources for example)
|
||||
const apiByKind = apiManager.getApi(api => api.kind === kind);
|
||||
if (apiByKind) {
|
||||
return apiByKind.getUrl({ name, namespace })
|
||||
}
|
||||
|
||||
// otherwise generate link with default prefix
|
||||
// resource still might exists in k8s, but api is not registered in the app
|
||||
return KubeApi.createLink({ apiVersion, name, namespace, resource })
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { JsonApi, JsonApiData, JsonApiError } from "./json-api";
|
||||
|
||||
export interface KubeJsonApiDataList<T = KubeJsonApiData> {
|
||||
kind: string;
|
||||
apiVersion: string;
|
||||
items: T[];
|
||||
metadata: {
|
||||
resourceVersion: string;
|
||||
selfLink: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface KubeJsonApiData extends JsonApiData {
|
||||
kind: string;
|
||||
apiVersion: string;
|
||||
metadata: {
|
||||
uid: string;
|
||||
name: string;
|
||||
namespace?: string;
|
||||
creationTimestamp?: string;
|
||||
resourceVersion: string;
|
||||
continue?: string;
|
||||
finalizers?: string[];
|
||||
selfLink: string;
|
||||
labels?: {
|
||||
[label: string]: string;
|
||||
};
|
||||
annotations?: {
|
||||
[annotation: string]: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface IKubeObjectRef {
|
||||
kind: string;
|
||||
apiVersion: string;
|
||||
name: string;
|
||||
namespace?: string;
|
||||
}
|
||||
|
||||
export interface KubeJsonApiError extends JsonApiError {
|
||||
code: number;
|
||||
status: string;
|
||||
message?: string;
|
||||
reason: string;
|
||||
details: {
|
||||
name: string;
|
||||
kind: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IKubeJsonApiQuery {
|
||||
watch?: any;
|
||||
resourceVersion?: string;
|
||||
timeoutSeconds?: number;
|
||||
limit?: number; // doesn't work with ?watch
|
||||
continue?: string; // might be used with ?limit from second request
|
||||
}
|
||||
|
||||
export class KubeJsonApi extends JsonApi<KubeJsonApiData> {
|
||||
protected parseError(error: KubeJsonApiError | any, res: Response): string[] {
|
||||
const { status, reason, message } = error;
|
||||
if (status && reason) {
|
||||
return [message || `${status}: ${reason}`];
|
||||
}
|
||||
return super.parseError(error, res);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// Base class for all kubernetes objects
|
||||
|
||||
import moment from "moment";
|
||||
import { KubeJsonApiData, KubeJsonApiDataList } from "./kube-json-api";
|
||||
import { autobind, formatDuration } from "../utils";
|
||||
import { ItemObject } from "../item.store";
|
||||
import { apiKube } from "./index";
|
||||
import { resourceApplierApi } from "./endpoints/resource-applier.api";
|
||||
|
||||
export type IKubeObjectConstructor<T extends KubeObject = any> = (new (data: KubeJsonApiData | any) => T) & {
|
||||
kind?: string;
|
||||
};
|
||||
|
||||
export interface IKubeObjectMetadata {
|
||||
uid: string;
|
||||
name: string;
|
||||
namespace?: string;
|
||||
creationTimestamp: string;
|
||||
resourceVersion: string;
|
||||
selfLink: string;
|
||||
deletionTimestamp?: string;
|
||||
finalizers?: string[];
|
||||
continue?: string; // provided when used "?limit=" query param to fetch objects list
|
||||
labels?: {
|
||||
[label: string]: string;
|
||||
};
|
||||
annotations?: {
|
||||
[annotation: string]: string;
|
||||
};
|
||||
ownerReferences?: {
|
||||
apiVersion: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
uid: string;
|
||||
controller: boolean;
|
||||
blockOwnerDeletion: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
export type IKubeMetaField = keyof KubeObject["metadata"];
|
||||
|
||||
@autobind()
|
||||
export class KubeObject implements ItemObject {
|
||||
static readonly kind: string;
|
||||
|
||||
static create(data: any) {
|
||||
return new KubeObject(data);
|
||||
}
|
||||
|
||||
static isNonSystem(item: KubeJsonApiData | KubeObject) {
|
||||
return !item.metadata.name.startsWith("system:");
|
||||
}
|
||||
|
||||
static isJsonApiData(object: any): object is KubeJsonApiData {
|
||||
return !object.items && object.metadata;
|
||||
}
|
||||
|
||||
static isJsonApiDataList(object: any): object is KubeJsonApiDataList {
|
||||
return object.items && object.metadata;
|
||||
}
|
||||
|
||||
static stringifyLabels(labels: { [name: string]: string }): string[] {
|
||||
if (!labels) return [];
|
||||
return Object.entries(labels).map(([name, value]) => `${name}=${value}`)
|
||||
}
|
||||
|
||||
constructor(data: KubeJsonApiData) {
|
||||
Object.assign(this, data);
|
||||
}
|
||||
|
||||
apiVersion: string
|
||||
kind: string
|
||||
metadata: IKubeObjectMetadata;
|
||||
|
||||
get selfLink() {
|
||||
return this.metadata.selfLink
|
||||
}
|
||||
|
||||
getId() {
|
||||
return this.metadata.uid;
|
||||
}
|
||||
|
||||
getResourceVersion() {
|
||||
return this.metadata.resourceVersion;
|
||||
}
|
||||
|
||||
getName() {
|
||||
return this.metadata.name;
|
||||
}
|
||||
|
||||
getNs() {
|
||||
// avoid "null" serialization via JSON.stringify when post data
|
||||
return this.metadata.namespace || undefined;
|
||||
}
|
||||
|
||||
// todo: refactor with named arguments
|
||||
getAge(humanize = true, compact = true, fromNow = false) {
|
||||
if (fromNow) {
|
||||
return moment(this.metadata.creationTimestamp).fromNow();
|
||||
}
|
||||
const diff = new Date().getTime() - new Date(this.metadata.creationTimestamp).getTime();
|
||||
if (humanize) {
|
||||
return formatDuration(diff, compact);
|
||||
}
|
||||
return diff;
|
||||
}
|
||||
|
||||
getFinalizers(): string[] {
|
||||
return this.metadata.finalizers || [];
|
||||
}
|
||||
|
||||
getLabels(): string[] {
|
||||
return KubeObject.stringifyLabels(this.metadata.labels);
|
||||
}
|
||||
|
||||
getAnnotations(): string[] {
|
||||
const labels = KubeObject.stringifyLabels(this.metadata.annotations);
|
||||
return labels.filter(label => {
|
||||
const skip = resourceApplierApi.annotations.some(key => label.startsWith(key));
|
||||
return !skip;
|
||||
})
|
||||
}
|
||||
|
||||
getOwnerRefs() {
|
||||
const refs = this.metadata.ownerReferences || [];
|
||||
return refs.map(ownerRef => ({
|
||||
...ownerRef,
|
||||
namespace: this.getNs(),
|
||||
}))
|
||||
}
|
||||
|
||||
getSearchFields() {
|
||||
const { getName, getId, getNs, getAnnotations, getLabels } = this
|
||||
return [
|
||||
getName(),
|
||||
getNs(),
|
||||
getId(),
|
||||
...getLabels(),
|
||||
...getAnnotations(),
|
||||
]
|
||||
}
|
||||
|
||||
toPlainObject(): object {
|
||||
return JSON.parse(JSON.stringify(this));
|
||||
}
|
||||
|
||||
// use unified resource-applier api for updating all k8s objects
|
||||
async update<T extends KubeObject>(data: Partial<T>) {
|
||||
return resourceApplierApi.update<T>({
|
||||
...this.toPlainObject(),
|
||||
...data,
|
||||
});
|
||||
}
|
||||
|
||||
delete() {
|
||||
return apiKube.del(this.selfLink);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// Kubernetes watch-api consumer
|
||||
|
||||
import { computed, observable, reaction } from "mobx";
|
||||
import { stringify } from "querystring"
|
||||
import { autobind, EventEmitter, interval } from "../utils";
|
||||
import { KubeJsonApiData } from "./kube-json-api";
|
||||
import { KubeObjectStore } from "../kube-object.store";
|
||||
import { KubeApi } from "./kube-api";
|
||||
import { configStore } from "../config.store";
|
||||
import { apiManager } from "./api-manager";
|
||||
|
||||
export interface IKubeWatchEvent<T = any> {
|
||||
type: "ADDED" | "MODIFIED" | "DELETED";
|
||||
object?: T;
|
||||
}
|
||||
|
||||
export interface IKubeWatchRouteEvent {
|
||||
type: "STREAM_END";
|
||||
url: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
export interface IKubeWatchRouteQuery {
|
||||
api: string | string[];
|
||||
}
|
||||
|
||||
@autobind()
|
||||
export class KubeWatchApi {
|
||||
protected evtSource: EventSource;
|
||||
protected onData = new EventEmitter<[IKubeWatchEvent]>();
|
||||
protected apiUrl = configStore.apiPrefix.BASE + "/watch";
|
||||
protected subscribers = observable.map<KubeApi, number>();
|
||||
protected reconnectInterval = interval(60 * 5, this.reconnect); // background reconnect every 5min
|
||||
protected reconnectTimeoutMs = 5000;
|
||||
protected maxReconnectsOnError = 10;
|
||||
protected reconnectAttempts = this.maxReconnectsOnError;
|
||||
|
||||
constructor() {
|
||||
reaction(() => this.activeApis, () => this.connect(), {
|
||||
fireImmediately: true,
|
||||
delay: 500,
|
||||
});
|
||||
}
|
||||
|
||||
@computed get activeApis() {
|
||||
return Array.from(this.subscribers.keys());
|
||||
}
|
||||
|
||||
getSubscribersCount(api: KubeApi) {
|
||||
return this.subscribers.get(api) || 0;
|
||||
}
|
||||
|
||||
subscribe(...apis: KubeApi[]) {
|
||||
apis.forEach(api => {
|
||||
this.subscribers.set(api, this.getSubscribersCount(api) + 1);
|
||||
});
|
||||
return () => apis.forEach(api => {
|
||||
const count = this.getSubscribersCount(api) - 1;
|
||||
if (count <= 0) this.subscribers.delete(api);
|
||||
else this.subscribers.set(api, count);
|
||||
});
|
||||
}
|
||||
|
||||
protected getQuery(): Partial<IKubeWatchRouteQuery> {
|
||||
const { isClusterAdmin, allowedNamespaces } = configStore;
|
||||
return {
|
||||
api: this.activeApis.map(api => {
|
||||
if (isClusterAdmin) return api.getWatchUrl();
|
||||
return allowedNamespaces.map(namespace => api.getWatchUrl(namespace))
|
||||
}).flat()
|
||||
}
|
||||
}
|
||||
|
||||
// todo: maybe switch to websocket to avoid often reconnects
|
||||
@autobind()
|
||||
protected connect() {
|
||||
if (this.evtSource) this.disconnect(); // close previous connection
|
||||
if (!this.activeApis.length) {
|
||||
return;
|
||||
}
|
||||
const query = this.getQuery();
|
||||
const apiUrl = this.apiUrl + "?" + stringify(query);
|
||||
this.evtSource = new EventSource(apiUrl);
|
||||
this.evtSource.onmessage = this.onMessage;
|
||||
this.evtSource.onerror = this.onError;
|
||||
this.writeLog("CONNECTING", query.api);
|
||||
}
|
||||
|
||||
reconnect() {
|
||||
if (!this.evtSource || this.evtSource.readyState !== EventSource.OPEN) {
|
||||
this.reconnectAttempts = this.maxReconnectsOnError;
|
||||
this.connect();
|
||||
}
|
||||
}
|
||||
|
||||
protected disconnect() {
|
||||
if (!this.evtSource) return;
|
||||
this.evtSource.close();
|
||||
this.evtSource.onmessage = null;
|
||||
this.evtSource = null;
|
||||
}
|
||||
|
||||
protected onMessage(evt: MessageEvent) {
|
||||
if (!evt.data) return;
|
||||
const data = JSON.parse(evt.data);
|
||||
if ((data as IKubeWatchEvent).object) {
|
||||
this.onData.emit(data);
|
||||
}
|
||||
else {
|
||||
this.onRouteEvent(data);
|
||||
}
|
||||
}
|
||||
|
||||
protected async onRouteEvent({ type, url }: IKubeWatchRouteEvent) {
|
||||
if (type === "STREAM_END") {
|
||||
this.disconnect();
|
||||
const { apiBase, namespace } = KubeApi.parseApi(url);
|
||||
const api = apiManager.getApi(apiBase);
|
||||
if (api) {
|
||||
try {
|
||||
await api.refreshResourceVersion({ namespace });
|
||||
this.reconnect();
|
||||
} catch (error) {
|
||||
console.debug("failed to refresh resource version", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected onError(evt: MessageEvent) {
|
||||
const { reconnectAttempts: attemptsRemain, reconnectTimeoutMs } = this;
|
||||
if (evt.eventPhase === EventSource.CLOSED) {
|
||||
if (attemptsRemain > 0) {
|
||||
this.reconnectAttempts--;
|
||||
setTimeout(() => this.connect(), reconnectTimeoutMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected writeLog(...data: any[]) {
|
||||
if (configStore.isDevelopment) {
|
||||
console.log('%cKUBE-WATCH-API:', `font-weight: bold`, ...data);
|
||||
}
|
||||
}
|
||||
|
||||
addListener(store: KubeObjectStore, callback: (evt: IKubeWatchEvent) => void) {
|
||||
const listener = (evt: IKubeWatchEvent<KubeJsonApiData>) => {
|
||||
const { selfLink, namespace, resourceVersion } = evt.object.metadata;
|
||||
const api = apiManager.getApi(selfLink);
|
||||
api.setResourceVersion(namespace, resourceVersion);
|
||||
api.setResourceVersion("", resourceVersion);
|
||||
if (store == apiManager.getStore(api)) {
|
||||
callback(evt);
|
||||
}
|
||||
};
|
||||
this.onData.addListener(listener);
|
||||
return () => this.onData.removeListener(listener);
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.subscribers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const kubeWatchApi = new KubeWatchApi();
|
||||
@@ -0,0 +1,14 @@
|
||||
import { configStore } from "../config.store";
|
||||
|
||||
export function isAllowedResource(resources: string | string[]) {
|
||||
if (!Array.isArray(resources)) {
|
||||
resources = [resources];
|
||||
}
|
||||
const { allowedResources } = configStore;
|
||||
for (const resource of resources) {
|
||||
if (!allowedResources.includes(resource)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { stringify } from "querystring";
|
||||
import { autobind, base64, EventEmitter, interval } from "../utils";
|
||||
import { WebSocketApi } from "./websocket-api";
|
||||
import { configStore } from "../config.store";
|
||||
import isEqual from "lodash/isEqual"
|
||||
|
||||
export enum TerminalChannels {
|
||||
STDIN = 0,
|
||||
STDOUT = 1,
|
||||
STDERR = 2,
|
||||
TERMINAL_SIZE = 4,
|
||||
TOKEN = 9,
|
||||
}
|
||||
|
||||
enum TerminalColor {
|
||||
RED = "\u001b[31m",
|
||||
GREEN = "\u001b[32m",
|
||||
YELLOW = "\u001b[33m",
|
||||
BLUE = "\u001b[34m",
|
||||
MAGENTA = "\u001b[35m",
|
||||
CYAN = "\u001b[36m",
|
||||
GRAY = "\u001b[90m",
|
||||
LIGHT_GRAY = "\u001b[37m",
|
||||
NO_COLOR = "\u001b[0m",
|
||||
}
|
||||
|
||||
export interface ITerminalApiOptions {
|
||||
id: string;
|
||||
node?: string;
|
||||
colorTheme?: "light" | "dark";
|
||||
}
|
||||
|
||||
export class TerminalApi extends WebSocketApi {
|
||||
protected size: { Width: number; Height: number };
|
||||
protected currentToken: string;
|
||||
protected tokenInterval = interval(60, this.sendNewToken); // refresh every minute
|
||||
|
||||
public onReady = new EventEmitter<[]>();
|
||||
public isReady = false;
|
||||
|
||||
constructor(protected options: ITerminalApiOptions) {
|
||||
super({
|
||||
logging: configStore.isDevelopment,
|
||||
flushOnOpen: false,
|
||||
pingIntervalSeconds: 30,
|
||||
});
|
||||
}
|
||||
|
||||
async getUrl(token: string) {
|
||||
const { hostname, protocol } = location;
|
||||
const { id, node } = this.options;
|
||||
const apiPrefix = configStore.apiPrefix.TERMINAL;
|
||||
const wss = `ws${protocol === "https:" ? "s" : ""}://`;
|
||||
const queryParams = { token, id };
|
||||
if (node) {
|
||||
Object.assign(queryParams, {
|
||||
node: node,
|
||||
type: "node"
|
||||
});
|
||||
}
|
||||
|
||||
return `${wss}${hostname}${configStore.serverPort}${apiPrefix}/api?${stringify(queryParams)}`;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
const token = await configStore.getToken();
|
||||
const apiUrl = await this.getUrl(token);
|
||||
const { colorTheme } = this.options;
|
||||
this.emitStatus("Connecting ...", {
|
||||
color: colorTheme == "light" ? TerminalColor.GRAY : TerminalColor.LIGHT_GRAY
|
||||
});
|
||||
this.onData.addListener(this._onReady, { prepend: true });
|
||||
this.currentToken = token;
|
||||
this.tokenInterval.start();
|
||||
return super.connect(apiUrl);
|
||||
}
|
||||
|
||||
@autobind()
|
||||
async sendNewToken() {
|
||||
const token = await configStore.getToken();
|
||||
if (!this.isReady || token == this.currentToken) return;
|
||||
this.sendCommand(token, TerminalChannels.TOKEN);
|
||||
this.currentToken = token;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (!this.socket) return;
|
||||
const exitCode = String.fromCharCode(4); // ctrl+d
|
||||
this.sendCommand(exitCode);
|
||||
this.tokenInterval.stop();
|
||||
setTimeout(() => super.destroy(), 2000);
|
||||
}
|
||||
|
||||
removeAllListeners() {
|
||||
super.removeAllListeners();
|
||||
this.onReady.removeAllListeners();
|
||||
}
|
||||
|
||||
@autobind()
|
||||
protected _onReady(data: string) {
|
||||
if (!data) return;
|
||||
this.isReady = true;
|
||||
this.onReady.emit();
|
||||
this.onData.removeListener(this._onReady);
|
||||
this.flush();
|
||||
this.onData.emit(data); // re-emit data
|
||||
return false; // prevent calling rest of listeners
|
||||
}
|
||||
|
||||
reconnect() {
|
||||
const { reconnectDelaySeconds } = this.params;
|
||||
super.reconnect();
|
||||
}
|
||||
|
||||
sendCommand(key: string, channel = TerminalChannels.STDIN) {
|
||||
return this.send(channel + base64.encode(key));
|
||||
}
|
||||
|
||||
sendTerminalSize(cols: number, rows: number) {
|
||||
const newSize = { Width: cols, Height: rows };
|
||||
if (!isEqual(this.size, newSize)) {
|
||||
this.sendCommand(JSON.stringify(newSize), TerminalChannels.TERMINAL_SIZE);
|
||||
this.size = newSize;
|
||||
}
|
||||
}
|
||||
|
||||
protected parseMessage(data: string) {
|
||||
data = data.substr(1); // skip channel
|
||||
return base64.decode(data);
|
||||
}
|
||||
|
||||
protected _onOpen(evt: Event) {
|
||||
// Client should send terminal size in special channel 4,
|
||||
// But this size will be changed by terminal.fit()
|
||||
this.sendTerminalSize(120, 80);
|
||||
super._onOpen(evt);
|
||||
}
|
||||
|
||||
protected _onClose(evt: CloseEvent) {
|
||||
super._onClose(evt);
|
||||
this.isReady = false;
|
||||
}
|
||||
|
||||
protected emitStatus(data: string, options: { color?: TerminalColor; showTime?: boolean } = {}) {
|
||||
const { color, showTime } = options;
|
||||
if (color) {
|
||||
data = `${color}${data}${TerminalColor.NO_COLOR}`;
|
||||
}
|
||||
let time;
|
||||
if (showTime) {
|
||||
time = (new Date()).toLocaleString() + " ";
|
||||
}
|
||||
this.onData.emit(`${showTime ? time : ""}${data}\r\n`);
|
||||
}
|
||||
|
||||
protected emitError(error: string) {
|
||||
this.emitStatus(error, {
|
||||
color: TerminalColor.RED
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { observable } from "mobx";
|
||||
import { EventEmitter } from "../utils/eventEmitter";
|
||||
|
||||
interface IParams {
|
||||
url?: string; // connection url, starts with ws:// or wss://
|
||||
autoConnect?: boolean; // auto-connect in constructor
|
||||
flushOnOpen?: boolean; // flush pending commands on open socket
|
||||
reconnectDelaySeconds?: number; // reconnect timeout in case of error (0 - don't reconnect)
|
||||
pingIntervalSeconds?: number; // send ping message for keeping connection alive in some env, e.g. AWS (0 - don't ping)
|
||||
logging?: boolean; // show logs in console
|
||||
}
|
||||
|
||||
interface IMessage {
|
||||
id: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export enum WebSocketApiState {
|
||||
PENDING = -1,
|
||||
OPEN,
|
||||
CONNECTING,
|
||||
RECONNECTING,
|
||||
CLOSED,
|
||||
}
|
||||
|
||||
export class WebSocketApi {
|
||||
protected socket: WebSocket;
|
||||
protected pendingCommands: IMessage[] = [];
|
||||
protected reconnectTimer: any;
|
||||
protected pingTimer: any;
|
||||
protected pingMessage = "PING";
|
||||
|
||||
@observable readyState = WebSocketApiState.PENDING;
|
||||
|
||||
public onOpen = new EventEmitter<[]>();
|
||||
public onData = new EventEmitter<[string]>();
|
||||
public onClose = new EventEmitter<[]>();
|
||||
|
||||
static defaultParams: Partial<IParams> = {
|
||||
autoConnect: true,
|
||||
logging: false,
|
||||
reconnectDelaySeconds: 10,
|
||||
pingIntervalSeconds: 0,
|
||||
flushOnOpen: true,
|
||||
};
|
||||
|
||||
constructor(protected params: IParams) {
|
||||
this.params = Object.assign({}, WebSocketApi.defaultParams, params);
|
||||
const { autoConnect, pingIntervalSeconds } = this.params;
|
||||
if (autoConnect) {
|
||||
setTimeout(() => this.connect());
|
||||
}
|
||||
if (pingIntervalSeconds) {
|
||||
this.pingTimer = setInterval(() => this.ping(), pingIntervalSeconds * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
get isConnected() {
|
||||
const state = this.socket ? this.socket.readyState : -1;
|
||||
return state === WebSocket.OPEN && this.isOnline;
|
||||
}
|
||||
|
||||
get isOnline() {
|
||||
return navigator.onLine;
|
||||
}
|
||||
|
||||
setParams(params: Partial<IParams>) {
|
||||
Object.assign(this.params, params);
|
||||
}
|
||||
|
||||
connect(url = this.params.url) {
|
||||
if (this.socket) {
|
||||
this.socket.close(); // close previous connection first
|
||||
}
|
||||
this.socket = new WebSocket(url);
|
||||
this.socket.onopen = this._onOpen.bind(this);
|
||||
this.socket.onmessage = this._onMessage.bind(this);
|
||||
this.socket.onerror = this._onError.bind(this);
|
||||
this.socket.onclose = this._onClose.bind(this);
|
||||
this.readyState = WebSocketApiState.CONNECTING;
|
||||
}
|
||||
|
||||
ping() {
|
||||
if (!this.isConnected) return;
|
||||
this.send(this.pingMessage);
|
||||
}
|
||||
|
||||
reconnect() {
|
||||
const { reconnectDelaySeconds } = this.params;
|
||||
if (!reconnectDelaySeconds) return;
|
||||
this.writeLog('reconnect after', reconnectDelaySeconds + "ms");
|
||||
this.reconnectTimer = setTimeout(() => this.connect(), reconnectDelaySeconds * 1000);
|
||||
this.readyState = WebSocketApiState.RECONNECTING;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (!this.socket) return;
|
||||
this.socket.close();
|
||||
this.socket = null;
|
||||
this.pendingCommands = [];
|
||||
this.removeAllListeners();
|
||||
clearTimeout(this.reconnectTimer);
|
||||
clearInterval(this.pingTimer);
|
||||
this.readyState = WebSocketApiState.PENDING;
|
||||
}
|
||||
|
||||
removeAllListeners() {
|
||||
this.onOpen.removeAllListeners();
|
||||
this.onData.removeAllListeners();
|
||||
this.onClose.removeAllListeners();
|
||||
}
|
||||
|
||||
send(command: string) {
|
||||
const msg: IMessage = {
|
||||
id: (Math.random() * Date.now()).toString(16).replace(".", ""),
|
||||
data: command,
|
||||
};
|
||||
if (this.isConnected) {
|
||||
this.socket.send(msg.data);
|
||||
}
|
||||
else {
|
||||
this.pendingCommands.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
protected flush() {
|
||||
this.pendingCommands.forEach(msg => this.send(msg.data));
|
||||
this.pendingCommands.length = 0;
|
||||
}
|
||||
|
||||
protected parseMessage(data: string) {
|
||||
return data;
|
||||
}
|
||||
|
||||
protected _onOpen(evt: Event) {
|
||||
this.onOpen.emit();
|
||||
if (this.params.flushOnOpen) this.flush();
|
||||
this.readyState = WebSocketApiState.OPEN;
|
||||
this.writeLog('%cOPEN', 'color:green;font-weight:bold;', evt);
|
||||
}
|
||||
|
||||
protected _onMessage(evt: MessageEvent) {
|
||||
const data = this.parseMessage(evt.data);
|
||||
this.onData.emit(data);
|
||||
this.writeLog('%cMESSAGE', 'color:black;font-weight:bold;', data);
|
||||
}
|
||||
|
||||
protected _onError(evt: Event) {
|
||||
this.writeLog('%cERROR', 'color:red;font-weight:bold;', evt)
|
||||
}
|
||||
|
||||
protected _onClose(evt: CloseEvent) {
|
||||
const error = evt.code !== 1000 || !evt.wasClean;
|
||||
if (error) {
|
||||
this.reconnect();
|
||||
}
|
||||
else {
|
||||
this.readyState = WebSocketApiState.CLOSED;
|
||||
this.onClose.emit();
|
||||
}
|
||||
this.writeLog('%cCLOSE', `color:${error ? "red" : "black"};font-weight:bold;`, evt);
|
||||
}
|
||||
|
||||
protected writeLog(...data: any[]) {
|
||||
if (this.params.logging) {
|
||||
console.log(...data);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import get from "lodash/get";
|
||||
import { IKubeObjectMetadata, KubeObject } from "./kube-object";
|
||||
|
||||
interface IToleration {
|
||||
key?: string;
|
||||
operator?: string;
|
||||
effect?: string;
|
||||
tolerationSeconds?: number;
|
||||
}
|
||||
|
||||
interface IMatchExpression {
|
||||
key: string;
|
||||
operator: string;
|
||||
values: string[];
|
||||
}
|
||||
|
||||
interface INodeAffinity {
|
||||
nodeSelectorTerms?: {
|
||||
matchExpressions: IMatchExpression[];
|
||||
}[];
|
||||
weight: number;
|
||||
preference: {
|
||||
matchExpressions: IMatchExpression[];
|
||||
};
|
||||
}
|
||||
|
||||
interface IPodAffinity {
|
||||
labelSelector: {
|
||||
matchExpressions: IMatchExpression[];
|
||||
};
|
||||
topologyKey: string;
|
||||
}
|
||||
|
||||
export interface IAffinity {
|
||||
nodeAffinity?: {
|
||||
requiredDuringSchedulingIgnoredDuringExecution?: INodeAffinity[];
|
||||
preferredDuringSchedulingIgnoredDuringExecution?: INodeAffinity[];
|
||||
};
|
||||
podAffinity?: {
|
||||
requiredDuringSchedulingIgnoredDuringExecution?: IPodAffinity[];
|
||||
preferredDuringSchedulingIgnoredDuringExecution?: IPodAffinity[];
|
||||
};
|
||||
podAntiAffinity?: {
|
||||
requiredDuringSchedulingIgnoredDuringExecution?: IPodAffinity[];
|
||||
preferredDuringSchedulingIgnoredDuringExecution?: IPodAffinity[];
|
||||
};
|
||||
}
|
||||
|
||||
export class WorkloadKubeObject extends KubeObject {
|
||||
|
||||
// fixme: add type
|
||||
spec: any;
|
||||
|
||||
getSelectors(): string[] {
|
||||
const selector = this.spec.selector;
|
||||
return KubeObject.stringifyLabels(selector ? selector.matchLabels : null);
|
||||
}
|
||||
|
||||
getNodeSelectors(): string[] {
|
||||
const nodeSelector = get(this, "spec.template.spec.nodeSelector");
|
||||
return KubeObject.stringifyLabels(nodeSelector);
|
||||
}
|
||||
|
||||
getTemplateLabels(): string[] {
|
||||
const labels = get(this, "spec.template.metadata.labels");
|
||||
return KubeObject.stringifyLabels(labels);
|
||||
}
|
||||
|
||||
getTolerations(): IToleration[] {
|
||||
return get(this, "spec.template.spec.tolerations", [])
|
||||
}
|
||||
|
||||
getAffinity(): IAffinity {
|
||||
return get(this, "spec.template.spec.affinity")
|
||||
}
|
||||
|
||||
getAffinityNumber() {
|
||||
const affinity = this.getAffinity()
|
||||
if (!affinity) return 0
|
||||
return Object.keys(affinity).length
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from "react";
|
||||
import { Notifications } from "./components/notifications";
|
||||
import { Trans } from "@lingui/macro";
|
||||
|
||||
export function browserCheck() {
|
||||
const ua = window.navigator.userAgent
|
||||
const msie = ua.indexOf('MSIE ') // IE < 11
|
||||
const trident = ua.indexOf('Trident/') // IE 11
|
||||
const edge = ua.indexOf('Edge') // Edge
|
||||
if (msie > 0 || trident > 0 || edge > 0) {
|
||||
Notifications.info(
|
||||
<p>
|
||||
<Trans>
|
||||
<b>Your browser does not support all Lens features. </b>{" "}
|
||||
Please consider using another browser.
|
||||
</Trans>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./not-found"
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from "react";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import { MainLayout } from "../layout/main-layout";
|
||||
|
||||
export class NotFound extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<MainLayout className="NotFound" contentClass="flex" footer={null}>
|
||||
<p className="box center">
|
||||
<Trans>Page not found</Trans>
|
||||
</p>
|
||||
</MainLayout>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
.HelmChartDetails {
|
||||
.intro-logo {
|
||||
margin-right: $margin * 2;
|
||||
background: $helmLogoBackground;
|
||||
border-radius: $radius;
|
||||
max-width: 150px;
|
||||
max-height: 100px;
|
||||
padding: $padding;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
.intro-contents {
|
||||
.description {
|
||||
font-weight: bold;
|
||||
color: $textColorAccent;
|
||||
padding-bottom: $padding;
|
||||
|
||||
.Button {
|
||||
padding-left: $padding * 3;
|
||||
padding-right: $padding * 3;
|
||||
margin-left: $margin * 2;
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.version {
|
||||
.Select {
|
||||
width: 80px;
|
||||
min-width: 80px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.Icon {
|
||||
margin-right: $margin;
|
||||
}
|
||||
}
|
||||
|
||||
.maintainers {
|
||||
a {
|
||||
display: inline-block;
|
||||
margin-right: $margin;
|
||||
}
|
||||
}
|
||||
|
||||
.DrawerItem {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-description {
|
||||
margin-top: $margin * 2;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import "./helm-chart-details.scss";
|
||||
|
||||
import React, { Component } from "react";
|
||||
import { HelmChart, helmChartsApi } from "../../api/endpoints/helm-charts.api";
|
||||
import { t, Trans } from "@lingui/macro";
|
||||
import { autorun, observable } from "mobx";
|
||||
import { disposeOnUnmount, observer } from "mobx-react";
|
||||
import { Drawer, DrawerItem } from "../drawer";
|
||||
import { autobind, stopPropagation } from "../../utils";
|
||||
import { MarkdownViewer } from "../markdown-viewer";
|
||||
import { Spinner } from "../spinner";
|
||||
import { CancelablePromise } from "../../utils/cancelableFetch";
|
||||
import { Button } from "../button";
|
||||
import { Select, SelectOption } from "../select";
|
||||
import { createInstallChartTab } from "../dock/install-chart.store";
|
||||
import { Badge } from "../badge";
|
||||
import { _i18n } from "../../i18n";
|
||||
|
||||
interface Props {
|
||||
chart: HelmChart;
|
||||
hideDetails(): void;
|
||||
}
|
||||
|
||||
@observer
|
||||
export class HelmChartDetails extends Component<Props> {
|
||||
@observable chartVersions: HelmChart[];
|
||||
@observable selectedChart: HelmChart;
|
||||
@observable description: string = null;
|
||||
|
||||
private chartPromise: CancelablePromise<{ readme: string; versions: HelmChart[] }>;
|
||||
|
||||
@disposeOnUnmount
|
||||
chartSelector = autorun(async () => {
|
||||
if (!this.props.chart) return;
|
||||
this.chartVersions = null;
|
||||
this.selectedChart = null;
|
||||
this.description = null;
|
||||
this.loadChartData();
|
||||
this.chartPromise.then(data => {
|
||||
this.description = data.readme;
|
||||
this.chartVersions = data.versions;
|
||||
this.selectedChart = data.versions[0];
|
||||
});
|
||||
});
|
||||
|
||||
loadChartData(version?: string) {
|
||||
const { chart: { name, repo } } = this.props;
|
||||
if (this.chartPromise) this.chartPromise.cancel();
|
||||
this.chartPromise = helmChartsApi.get(repo, name, version);
|
||||
}
|
||||
|
||||
@autobind()
|
||||
onVersionChange(opt: SelectOption) {
|
||||
const version = opt.value;
|
||||
this.selectedChart = this.chartVersions.find(chart => chart.version === version);
|
||||
this.description = null;
|
||||
this.loadChartData(version);
|
||||
this.chartPromise.then(data => {
|
||||
this.description = data.readme
|
||||
});
|
||||
}
|
||||
|
||||
@autobind()
|
||||
install() {
|
||||
createInstallChartTab(this.selectedChart);
|
||||
this.props.hideDetails()
|
||||
}
|
||||
|
||||
renderIntroduction() {
|
||||
const { selectedChart, chartVersions, onVersionChange } = this;
|
||||
const placeholder = require("./helm-placeholder.svg");
|
||||
return (
|
||||
<div className="introduction flex align-flex-start">
|
||||
<img
|
||||
className="intro-logo"
|
||||
src={selectedChart.getIcon() || placeholder}
|
||||
onError={(event) => event.currentTarget.src = placeholder}
|
||||
/>
|
||||
<div className="intro-contents box grow">
|
||||
<div className="description flex align-center justify-space-between">
|
||||
{selectedChart.getDescription()}
|
||||
<Button primary label={_i18n._(t`Install`)} onClick={this.install}/>
|
||||
</div>
|
||||
<DrawerItem name={_i18n._(t`Version`)} className="version" onClick={stopPropagation}>
|
||||
<Select
|
||||
themeName="outlined"
|
||||
menuPortalTarget={null}
|
||||
options={chartVersions.map(chart => chart.version)}
|
||||
value={selectedChart.getVersion()}
|
||||
onChange={onVersionChange}
|
||||
/>
|
||||
</DrawerItem>
|
||||
<DrawerItem name={_i18n._(t`Home`)}>
|
||||
<a href={selectedChart.getHome()} target="_blank">{selectedChart.getHome()}</a>
|
||||
</DrawerItem>
|
||||
<DrawerItem name={_i18n._(t`Maintainers`)} className="maintainers">
|
||||
{selectedChart.getMaintainers().map(({ name, email, url }) =>
|
||||
<a key={name} href={url ? url : `mailto:${email}`} target="_blank">{name}</a>
|
||||
)}
|
||||
</DrawerItem>
|
||||
{selectedChart.getKeywords().length > 0 && (
|
||||
<DrawerItem name={_i18n._(t`Keywords`)} labelsOnly>
|
||||
{selectedChart.getKeywords().map(key => <Badge key={key} label={key}/>)}
|
||||
</DrawerItem>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderContent() {
|
||||
if (this.selectedChart === null || this.description === null) return <Spinner center/>;
|
||||
return (
|
||||
<div className="box grow">
|
||||
{this.renderIntroduction()}
|
||||
<div className="chart-description">
|
||||
<MarkdownViewer markdown={this.description}/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { chart, hideDetails } = this.props;
|
||||
const title = chart ? <Trans>Chart: {chart.getFullName()}</Trans> : "";
|
||||
return (
|
||||
<Drawer
|
||||
className="HelmChartDetails"
|
||||
usePortal={true}
|
||||
open={!!chart}
|
||||
title={title}
|
||||
onClose={hideDetails}
|
||||
>
|
||||
{this.renderContent()}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import semver from "semver"
|
||||
import { observable } from "mobx";
|
||||
import { autobind } from "../../utils";
|
||||
import { HelmChart, helmChartsApi } from "../../api/endpoints/helm-charts.api";
|
||||
import { ItemStore } from "../../item.store";
|
||||
import flatten from "lodash/flatten"
|
||||
|
||||
export interface IChartVersion {
|
||||
repo: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
@autobind()
|
||||
export class HelmChartStore extends ItemStore<HelmChart> {
|
||||
@observable versions = observable.map<string, IChartVersion[]>();
|
||||
|
||||
loadAll() {
|
||||
return this.loadItems(() => helmChartsApi.list());
|
||||
}
|
||||
|
||||
getByName(name: string, repo: string) {
|
||||
return this.items.find(chart => chart.getName() === name && chart.getRepository() === repo);
|
||||
}
|
||||
|
||||
protected sortVersions = (versions: IChartVersion[]) => {
|
||||
return versions.sort((first, second) => {
|
||||
return semver.compare(second.version, first.version)
|
||||
});
|
||||
};
|
||||
|
||||
async getVersions(chartName: string, force?: boolean): Promise<IChartVersion[]> {
|
||||
let versions = this.versions.get(chartName);
|
||||
if (versions && !force) {
|
||||
return versions;
|
||||
}
|
||||
const loadVersions = (repo: string) => {
|
||||
return helmChartsApi.get(repo, chartName).then(({ versions }) => {
|
||||
return versions.map(chart => ({
|
||||
repo: repo,
|
||||
version: chart.getVersion()
|
||||
}))
|
||||
})
|
||||
};
|
||||
if (!this.isLoaded) {
|
||||
await this.loadAll();
|
||||
}
|
||||
const repos = this.items
|
||||
.filter(chart => chart.getName() === chartName)
|
||||
.map(chart => chart.getRepository());
|
||||
versions = await Promise.all(repos.map(loadVersions))
|
||||
.then(flatten)
|
||||
.then(this.sortVersions);
|
||||
|
||||
this.versions.set(chartName, versions);
|
||||
return versions;
|
||||
}
|
||||
|
||||
reset() {
|
||||
super.reset();
|
||||
this.versions.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const helmChartStore = new HelmChartStore();
|
||||
@@ -0,0 +1,14 @@
|
||||
import { RouteProps } from "react-router"
|
||||
import { appsRoute } from "../+apps/apps.route";
|
||||
import { buildURL } from "../../navigation";
|
||||
|
||||
export const helmChartsRoute: RouteProps = {
|
||||
path: appsRoute.path + "/charts/:repo?/:chartName?"
|
||||
}
|
||||
|
||||
export interface IHelmChartsRouteParams {
|
||||
chartName?: string;
|
||||
repo?: string;
|
||||
}
|
||||
|
||||
export const helmChartsURL = buildURL<IHelmChartsRouteParams>(helmChartsRoute.path)
|
||||
Loaded 100 of 807 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user