Merge pull request #276 from codyrancher/node

Adding the 'node' list and detail view
This commit is contained in:
Westly Wright 2020-01-17 08:38:53 -07:00 committed by GitHub
commit 12b13365bd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 1023 additions and 7 deletions

View File

@ -53,7 +53,8 @@ $selected: rgba($primary, .5);
success: $success,
info: $info,
warning: $warning,
error: $error
error: $error,
darker: $darker
), true);
--body-bg : #{$lightest};

59
components/Alert.vue Normal file
View File

@ -0,0 +1,59 @@
<script>
import VStack from '@/components/Layout/Stack/VStack';
const STATUS_CLASS_MAP = {
success: {
container: 'text-success',
icon: 'icon-x'
},
warning: {
container: 'text-warning',
icon: 'icon-x'
},
info: {
container: 'text-info',
icon: 'icon-x'
},
error: {
container: 'alert-bg-error text-error',
icon: 'icon-x'
}
};
export default {
components: { VStack },
props: {
status: {
type: String,
validator(value) {
return Object.keys(STATUS_CLASS_MAP).includes(value);
},
required: true
},
message: {
type: String,
required: true
}
},
computed: {
containerClasses() {
return STATUS_CLASS_MAP[this.status].container;
},
iconClasses() {
return STATUS_CLASS_MAP[this.status].icon;
}
}
};
</script>
<template>
<VStack class="alert" :class="containerClasses" vertical-align="center">
<div><i class="icon" :class="iconClasses" /> {{ message }}</div>
</VStack>
</template>
<style lang="scss" scoped>
.alert-bg-error {
background-color: rgba(var(--error), 0.5)
}
</style>

View File

@ -0,0 +1,80 @@
<script>
import PercentageCircle from '@/components/PercentageCircle';
import VStack from '@/components/Layout/Stack/VStack';
export default {
components: {
PercentageCircle,
VStack
},
props: {
resourceName: {
type: String,
required: true
},
capacity: {
type: Number,
required: true
},
used: {
type: Number,
required: true
},
units: {
type: String,
default: ''
},
numberFormatter: {
type: Function,
default: value => value
}
},
computed: {
displayUnits() {
return this.units
? ` ${ this.units }`
: '';
}
}
};
</script>
<template>
<VStack class="consumption-gauge" :show-dividers="true">
<PercentageCircle :value="used / capacity" :lower-error-bound="0.25" :lower-warning-bound="0.25" :upper-warning-bound="0.7" :upper-error-bound="0.85" />
<VStack horizontal-align="center">
<div>{{ resourceName }}</div>
<div class="amount">
{{ numberFormatter(used) }} of {{ numberFormatter(capacity) }}{{ displayUnits }} reserved
</div>
</VStack>
</VStack>
</template>
<style lang="scss" scoped>
$divider-spacing: 20px;
.consumption-gauge {
min-height: 300px;
width: 100%;
padding-right: $divider-spacing;
&:last-child {
padding-right: 0;
}
& > :first-child {
padding-bottom: $divider-spacing;
height: 75%;
}
& > :last-child {
padding-top: $divider-spacing;
height: 25%;
}
.amount {
color: var(--link-text);
}
}
</style>

View File

@ -0,0 +1,23 @@
<script>
export default {
props: {
text: {
type: String,
required: true,
},
},
methods: {
clicked(event) {
event.preventDefault();
this.$copyText(this.text);
},
}
};
</script>
<template>
<a href="#" @click="clicked">
{{ text }} <i class="icon icon-copy" />
</a>
</template>

29
components/DetailTop.vue Normal file
View File

@ -0,0 +1,29 @@
<template>
<div class="detail-top">
<slot />
</div>
</template>
<style lang="scss" scoped>
.detail-top {
display: flex;
& > * {
padding: 10px 50px;
display: flex;
flex-direction: column;
justify-content: center;
&:not(:last-child) {
border-right: 2px solid var(--border);
}
&:first-child {
padding-left: 0;
}
& >:not(:first-child) {
color: var(--input-label);
}
}
}
</style>

View File

@ -0,0 +1,10 @@
<script>
export default { props: { title: { type: String, default: '' } } };
</script>
<template>
<div>
<span>{{ title }}</span>
<span><slot /></span>
</div>
</template>

View File

@ -0,0 +1,13 @@
<script>
import Stack from './index';
export default {
extends: Stack,
props: {
direction: {
type: String,
default: 'horizontal'
}
}
};
</script>

View File

@ -0,0 +1,13 @@
<script>
import Stack from './index';
export default {
extends: Stack,
props: {
direction: {
type: String,
default: 'vertical'
}
}
};
</script>

View File

@ -0,0 +1,178 @@
<template>
<div class="stack" :class="classes">
<slot />
</div>
</template>
<script>
export default {
props: {
direction: {
type: String,
validator(value) {
return ['horizontal', 'vertical'].includes(value);
},
required: true
},
showDividers: { type: Boolean },
horizontalAlign: {
type: String,
validator(value) {
return ['left', 'right', 'center', 'space-between', 'space-around', 'space-evenly'].includes(value);
},
default: undefined
},
verticalAlign: {
type: String,
validator(value) {
return ['top', 'bottom', 'center', 'space-between', 'space-around', 'space-evenly'].includes(value);
},
default: undefined
},
},
computed: {
classes() {
return {
dividers: this.showDividers,
[this.direction]: true,
'h-align-left': this.horizontalAlign === 'left',
'h-align-right': this.horizontalAlign === 'right',
'h-align-center': this.horizontalAlign === 'center',
'h-align-space-between': this.horizontalAlign === 'space-between',
'h-align-space-around': this.horizontalAlign === 'space-around',
'h-align-space-evenly': this.horizontalAlign === 'space-evenly',
'v-align-top': this.verticalAlign === 'top',
'v-align-bottom': this.verticalAlign === 'bottom',
'v-align-center': this.verticalAlign === 'center',
'v-align-space-between': this.verticalAlign === 'space-between',
'v-align-space-around': this.verticalAlign === 'space-around',
'v-align-space-evenly': this.verticalAlign === 'space-evenly',
};
}
}
};
</script>
<style lang="scss" scoped>
.stack {
display: flex;
&.horizontal {
flex-direction: row;
&.dividers {
& > *:not(:last-child) {
border-right: 2px solid var(--border)
}
}
&.h-align-left {
justify-content: flex-start;
}
&.h-align-right {
justify-content: flex-end;
}
&.h-align-center {
justify-content: center;
}
&.h-align-space-between {
justify-content: space-between;
}
&.h-align-space-around {
justify-content: space-around;
}
&.h-align-space-evenly {
justify-content: space-evenly;
}
&.v-align-top {
align-items: flex-start;
}
&.v-align-bottom {
align-items: flex-end;
}
&.v-align-center {
align-items: center;
}
&.v-align-space-between {
align-items: space-between;
}
&.v-align-space-around {
align-items: space-around;
}
&.v-align-space-evenly {
align-items: space-evenly;
}
}
&.vertical {
flex-direction: column;
&.dividers {
& > *:not(:last-child) {
border-bottom: 2px solid var(--border)
}
}
&.h-align-left {
align-items: flex-start;
}
&.h-align-right {
align-items: flex-end;
}
&.h-align-center {
align-items: center;
}
&.h-align-space-between {
align-items: space-between;
}
&.h-align-space-around {
align-items: space-around;
}
&.h-align-space-evenly {
align-items: space-evenly;
}
&.v-align-top {
justify-content: flex-start;
}
&.v-align-bottom {
justify-content: flex-end;
}
&.v-align-center {
justify-content: center;
}
&.v-align-space-between {
justify-content: space-between;
}
&.v-align-space-around {
justify-content: space-around;
}
&.v-align-space-evenly {
justify-content: space-evenly;
}
}
}
</style>

View File

@ -0,0 +1,139 @@
<script>
import VStack from '@/components/Layout/Stack/VStack';
function polarToCartesian(centerX, centerY, radius, angleInDegrees) {
const angleInRadians = (angleInDegrees - 90) * Math.PI / 180.0;
return {
x: centerX + (radius * Math.cos(angleInRadians)),
y: centerY + (radius * Math.sin(angleInRadians))
};
}
function describeArc(x, y, radius, startAngle, endAngle) {
const start = polarToCartesian(x, y, radius, endAngle);
const end = polarToCartesian(x, y, radius, startAngle);
const largeArcFlag = endAngle - startAngle <= 180 ? '0' : '1';
const d = [
'M', start.x, start.y,
'A', radius, radius, 0, largeArcFlag, 0, end.x, end.y
].join(' ');
return d;
}
export default {
name: 'PercentageCircle',
components: { VStack },
props: {
value: {
type: Number,
required: true,
validator: (value) => {
return value >= 0 && value <= 1;
}
},
lowerWarningBound: { type: Number, default: undefined },
upperWarningBound: { type: Number, default: undefined },
lowerErrorBound: { type: Number, default: undefined },
upperErrorBound: { type: Number, default: undefined }
},
computed: {
valueD() {
return describeArc(50, 50, 45, 180, 180 + (360 * this.value));
},
printedValue() {
return (this.value * 100)
.toFixed(1)
.replace(/\.0$/, '');
},
valueClass() {
const errorClass = 'error';
const warningClass = 'warning';
const successClass = 'success';
if (this.lowerErrorBound && this.value <= this.lowerErrorBound) {
return errorClass;
}
if (this.lowerWarningBound && this.value <= this.lowerWarningBound) {
return warningClass;
}
if (this.upperErrorBound && this.value >= this.upperErrorBound) {
return errorClass;
}
if (this.upperWarningBound && this.value >= this.upperWarningBound) {
return warningClass;
}
return successClass;
}
}
};
</script>
<template>
<VStack class="percentage-circle" horizontal-align="center">
<svg viewBox="0 0 100 100" class="gauge">
<circle class="dial" fill="none" cx="50" cy="50" r="45" />
<path v-if="value < 1" class="value" :class="valueClass" fill="none" :d="valueD" />
<circle
v-else
class="value"
:class="valueClass"
fill="none"
cx="50"
cy="50"
r="45"
/>
</svg>
<div class="printed-value mt-10">
<h1>{{ printedValue }}%</h1>
</div>
</VStack>
</template>
<style lang="scss" scoped>
.percentage-circle {
text-align: center;
svg {
$size: 150px;
width: $size;
height: $size;
.dial {
stroke: var(--muted);
stroke-width: 5;
}
.value {
stroke-width: 5.5;
stroke-linecap: round;
&.success {
stroke: var(--success);
}
&.warning {
stroke: var(--warning);
}
&.error {
stroke: var(--error);
}
}
}
.printed-value {
text-align: center;
}
}
</style>

View File

@ -31,7 +31,17 @@ export default {
showGroups: {
type: Boolean,
default: true
}
},
search: {
// Show search input to filter rows
type: Boolean,
default: true
},
tableActions: {
// Show bulk table actions
type: Boolean,
default: true
},
},
computed: {
@ -145,8 +155,9 @@ export default {
:headers="_headers"
:rows="filteredRows"
:group-by="groupBy"
:search="search"
:table-actions="tableActions"
key-field="_key"
table-actions
v-on="$listeners"
>
<template v-if="groupable && showGroups" #header-middle>

View File

@ -215,7 +215,7 @@ export default {
</div>
</template>
<style lang='scss'>
<style lang='scss' scoped>
.flat {
border-collapse: collapse;
table-layout: fixed;

View File

@ -0,0 +1,67 @@
<script>
export default {
props: {
value: {
type: String,
default: ''
},
row: {
type: Object,
required: true
},
col: {
type: Object,
default: () => {}
},
},
data() {
return { NUMBER_OF_TICKS: 10 };
},
methods: {
getTickBackgroundClass(i) {
const valuePercentage = Number.parseFloat(this.value) / 100;
const barPercentage = i / this.NUMBER_OF_TICKS;
if (valuePercentage < barPercentage) {
return 'bg-darker';
}
if (barPercentage <= 0.6) {
return 'bg-success';
}
if (barPercentage <= 0.8) {
return 'bg-info';
}
return 'bg-error';
}
}
};
</script>
<template>
<span>
<span class="percentage">{{ value }}%</span>
<span class="bar">
<span v-for="i in NUMBER_OF_TICKS" :key="i" class="tick" :class="getTickBackgroundClass(i)">&nbsp;</span>
</span>
</span>
</template>
<style lang='scss'>
.percentage {
vertical-align: middle;
}
.bar {
vertical-align: middle;
margin-left: 3px;
.tick {
display: inline-block;
overflow: hidden;
margin-right: 3px;
width: 3px;
font-size: 1.2em;
}
}
</style>

View File

@ -1,10 +1,15 @@
import { CONFIG_MAP, SECRET, RIO, NAMESPACE } from '@/config/types';
import {
CONFIG_MAP, SECRET, RIO, NAMESPACE, NODE
} from '@/config/types';
import {
STATE, NAME, NAMESPACE_NAME, NAMESPACE_NAME_IMAGE, AGE,
WEIGHT, SCALE,
KEYS, ENDPOINTS,
MATCHES, DESTINATION,
TARGET, TARGET_KIND, USERNAME, USER_DISPLAY_NAME, USER_ID, USER_STATUS,
NODE_NAME, ROLES,
VERSION, CPU,
RAM, PODS
} from '@/config/table-headers';
import { _CREATE, _CLONE, _STAGE } from '@/config/query-params';
@ -39,6 +44,24 @@ export const FRIENDLY = {
],
},
nodes: {
singular: 'Node',
plural: 'Nodes',
type: NODE,
headers: [
STATE,
NODE_NAME,
ROLES,
VERSION,
CPU,
RAM,
PODS
],
search: false,
tableActions: false,
hasDetail: true
},
'public-domains': {
singular: 'Public Domain',
plural: 'Public Domains',

View File

@ -71,6 +71,52 @@ export const NODE = {
value: 'spec.nodeName',
};
export const NODE_NAME = {
name: 'nodeName',
label: 'Name',
sort: 'name',
value: 'name',
formatter: 'LinkDetail',
};
export const ROLES = {
name: 'roles',
label: 'Roles',
sort: 'roles',
value: 'roles'
};
export const VERSION = {
name: 'version',
label: 'Version',
sort: 'version',
value: 'version'
};
export const CPU = {
name: 'cpu',
label: 'CPU',
sort: 'cpu',
value: 'cpuUsage',
formatter: 'PercentageBar'
};
export const RAM = {
name: 'ram',
label: 'RAM',
sort: 'ram',
value: 'ramUsage',
formatter: 'PercentageBar'
};
export const PODS = {
name: 'pods',
label: 'Pods',
sort: 'pods',
value: 'podUsage',
formatter: 'PercentageBar'
};
export const AGE = {
name: 'age',
label: 'Age',
@ -208,6 +254,49 @@ export const USER_STATUS = {
formatter: 'BadgeState'
};
export const TYPE = {
name: 'type',
label: 'Type',
value: 'type',
sort: ['type']
};
export const STATUS = {
name: 'status',
label: 'Status',
value: 'status',
sort: ['status']
};
export const LAST_HEARTBEAT_TIME = {
name: 'lastHeartbeatTime',
label: 'Last update',
value: 'lastHeartbeatTime',
sort: ['lastHeartbeatTime'],
formatter: 'LiveDate',
};
export const REASON = {
name: 'reason',
label: 'Reason',
value: 'reason',
sort: ['reason']
};
export const MESSAGE = {
name: 'message',
label: 'Message',
value: 'message',
sort: ['message']
};
export const KEY = {
name: 'key',
label: 'Key',
value: 'key',
sort: ['key']
};
export const VALUE = {
name: 'value',
label: 'Value',
value: 'value',
sort: ['value']
};
export function headersFor(schema) {
const out = [];
const attributes = schema.attributes || {};

204
detail/core.v1.node.vue Normal file
View File

@ -0,0 +1,204 @@
<script>
import CopyToClipboardText from '@/components/CopyToClipboardText';
import ConsumptionGauge from '@/components/ConsumptionGauge';
import DetailTop from '@/components/DetailTop';
import DetailTopColumn from '@/components/DetailTopColumn';
import HStack from '@/components/Layout/Stack/HStack';
import VStack from '@/components/Layout/Stack/VStack';
import Alert from '@/components/Alert';
import SortableTable from '@/components/SortableTable';
import Tabbed from '@/components/Tabbed';
import Tab from '@/components/Tabbed/Tab';
import {
TYPE,
STATUS,
LAST_HEARTBEAT_TIME,
REASON,
MESSAGE,
KEY,
VALUE
} from '@/config/table-headers';
export default {
name: 'DetailNode',
components: {
Alert, ConsumptionGauge, CopyToClipboardText, DetailTop, DetailTopColumn, HStack, VStack, Tab, Tabbed, SortableTable
},
props: {
value: {
type: Object,
required: true,
},
},
data() {
return {
statusTableHeaders: [
TYPE,
STATUS,
LAST_HEARTBEAT_TIME,
REASON,
MESSAGE
],
systemTableHeaders: [
KEY,
VALUE
],
labelsTableHeaders: [
KEY,
VALUE
],
annotationsTableHeaders: [
KEY,
VALUE
],
};
},
computed: {
pidPressureStatus() {
return this.mapToStatus(this.value.isPidPressureOk);
},
diskPressureStatus() {
return this.mapToStatus(this.value.isDiskPressureOk);
},
memoryPressureStatus() {
return this.mapToStatus(this.value.isMemoryPressureOk);
},
kubeletStatus() {
return this.mapToStatus(this.value.isKubeletOk);
},
statusTableRows() {
return this.value.status.conditions;
},
systemTableRows() {
return Object.keys(this.value.status.nodeInfo)
.map(key => ({
key,
value: this.value.status.nodeInfo[key]
}));
},
labelsTableRows() {
return Object.keys(this.value.metadata.labels)
.map(key => ({
key,
value: this.value.metadata.labels[key]
}));
},
annotationsTableRows() {
return Object.keys(this.value.metadata.annotations)
.map(key => ({
key,
value: this.value.metadata.annotations[key]
}));
}
},
methods: {
memoryFormatter(value) {
return (value / 1000000).toFixed(2);
},
mapToStatus(isOk) {
return isOk
? 'success'
: 'error';
}
}
};
</script>
<template>
<VStack class="node">
<DetailTop>
<DetailTopColumn title="Description">
{{ value.id }}
</DetailTopColumn>
<DetailTopColumn title="IP Address">
<CopyToClipboardText :text="value.status.addresses[0].address" />
</DetailTopColumn>
<DetailTopColumn title="Version">
<CopyToClipboardText :text="value.status.nodeInfo.kubeletVersion" />
</DetailTopColumn>
<DetailTopColumn title="OS">
<CopyToClipboardText :text="value.status.nodeInfo.operatingSystem" />
</DetailTopColumn>
<DetailTopColumn title="Created">
<CopyToClipboardText :text="value.metadata.creationTimestamp" />
</DetailTopColumn>
</DetailTop>
<HStack class="glance" :show-dividers="true">
<VStack class="alerts" :show-dividers="true" vertical-align="space-evenly">
<Alert :status="pidPressureStatus" message="PID Pressure" />
<Alert :status="diskPressureStatus" message="Disk Pressure" />
<Alert :status="memoryPressureStatus" message="Memory Pressure" />
<Alert :status="kubeletStatus" message="Kubelet" />
</VStack>
<HStack class="cluster" horizontal-align="space-evenly">
<ConsumptionGauge resource-name="CPU" :capacity="value.cpuCapacity" :used="value.cpuConsumed" />
<ConsumptionGauge resource-name="MEMORY" :capacity="value.ramCapacity" :used="value.ramConsumed" units="GiB" :number-formatter="memoryFormatter" />
<ConsumptionGauge resource-name="PODS" :capacity="value.podCapacity" :used="value.podConsumed" />
</HStack>
</HStack>
<Tabbed default-tab="status">
<Tab name="status" label="Status">
<SortableTable
key-field="_key"
:headers="statusTableHeaders"
:rows="statusTableRows"
:row-actions="false"
/>
</Tab>
<Tab name="system" label="System">
<SortableTable
key-field="_key"
:headers="systemTableHeaders"
:rows="systemTableRows"
:row-actions="false"
/>
</Tab>
<Tab name="labels" label="Labels">
<SortableTable
key-field="_key"
:headers="labelsTableHeaders"
:rows="labelsTableRows"
:row-actions="false"
/>
</Tab>
<Tab name="annotations" label="Annotations">
<SortableTable
key-field="_key"
:headers="annotationsTableHeaders"
:rows="annotationsTableRows"
:row-actions="false"
/>
</Tab>
</Tabbed>
</VStack>
</template>
<style lang="scss" scoped>
.cluster {
flex: 1;
}
$divider-spacing: 20px;
.glance {
& > * {
padding: 0 $divider-spacing;
&:first-child {
padding-left: 0;
}
}
}
.alerts {
width: 25%;
& > * {
flex: 1;
}
}
</style>

65
models/core.v1.node.js Normal file
View File

@ -0,0 +1,65 @@
export default {
name() {
return this.metadata.name;
},
roles() {
console.warn('no backend for roles');
return 'All';
},
version() {
return this.status.nodeInfo.kubeletVersion;
},
cpuUsage() {
return calculatePercentage(this.status.capacity.cpu, this.status.allocatable.cpu);
},
cpuCapacity() {
return Number.parseInt(this.status.capacity.cpu);
},
cpuConsumed() {
return Number.parseInt(this.status.capacity.cpu) - Number.parseInt(this.status.allocatable.cpu);
},
ramUsage() {
return calculatePercentage(this.status.capacity.memory, this.status.allocatable.memory);
},
ramCapacity() {
return Number.parseInt(this.status.capacity.memory);
},
ramConsumed() {
return Number.parseInt(this.status.capacity.memory) - Number.parseInt(this.status.allocatable.memory);
},
podUsage() {
return calculatePercentage(this.status.capacity.pods, this.status.allocatable.pods);
},
podCapacity() {
return Number.parseInt(this.status.capacity.pods);
},
podConsumed() {
return Number.parseInt(this.status.capacity.pods) - Number.parseInt(this.status.allocatable.pods);
},
isPidPressureOk() {
return isConditionOk(this, 'PIDPressure');
},
isDiskPressureOk() {
return isConditionOk(this, 'DiskPressure');
},
isMemoryPressureOk() {
return isConditionOk(this, 'MemoryPressure');
},
isKubeletOk() {
return !isConditionOk(this, 'Ready');
}
};
function isConditionOk(that, type) {
const condition = that.status.conditions.find(condition => condition.type === type);
return condition.status === 'False';
}
function calculatePercentage(capacity, allocatable) {
const c = Number.parseFloat(capacity);
const a = Number.parseFloat(allocatable);
return (((c - a) / c) * 100).toString();
}

View File

@ -23,6 +23,18 @@ export default {
headers() {
return FRIENDLY[this.resource].headers;
},
search() {
return FRIENDLY[this.resource].search;
},
tableActions() {
return FRIENDLY[this.resource].tableActions;
},
showCreate() {
return typeof this.tableActions === 'undefined' || this.tableActions;
}
},
asyncData(ctx) {
@ -46,12 +58,12 @@ export default {
<h1>
{{ typeDisplay }}
</h1>
<div class="actions">
<div v-if="showCreate" class="actions">
<nuxt-link to="create" append tag="button" type="button" class="btn bg-primary">
Create
</nuxt-link>
</div>
</header>
<ResourceTable :schema="schema" :rows="rows" :headers="headers" />
<ResourceTable :schema="schema" :rows="rows" :headers="headers" :search="search" :table-actions="tableActions" />
</div>
</template>