Merge branch 'dev' into rules

This commit is contained in:
Eric Herbrandson
2019-06-22 16:56:41 -05:00
27 changed files with 4359 additions and 9342 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
# Stage 1 - the build react app
FROM node:11.10.0-alpine as build-deps
FROM node:12.4.0-alpine as build-deps
WORKDIR /usr/src/app
COPY client/package.json client/package-lock.json ./
RUN npm i
@@ -8,7 +8,7 @@ COPY client/ ./
RUN npm run build
# Stage 2 - the production environment
FROM node:11.10.0-alpine
FROM node:12.4.0-alpine
RUN apk add --no-cache tini
ENV NODE_ENV production
+6
View File
@@ -49,6 +49,12 @@ spec:
servicePort: 80
```
# kubectl proxy
Unfortunately, `kubectl proxy` can not be used to access k8dash. According to the information at [https://github.com/kubernetes/kubernetes/issues/38775#issuecomment-277915961](https://github.com/kubernetes/kubernetes/issues/38775#issuecomment-277915961), it seems that `kubectl proxy` strips the Authorization header when it proxies requests. From that link:
> this is working as expected. "proxying" through the apiserver will not get you standard proxy behavior (preserving Authorization headers end-to-end), because the API is not being used as a standard proxy
# Logging in
There are multiple options logging into the dashboard.
+3874 -8926
View File
File diff suppressed because it is too large Load Diff
+15 -13
View File
@@ -4,19 +4,21 @@
"private": true,
"homepage": ".",
"dependencies": {
"ansi-to-react": "^5.0.0",
"js-base64": "^2.5.1",
"js-cookie": "^2.2.0",
"lodash": "^4.17.11",
"moment": "^2.24.0",
"node-sass": "^4.11.0",
"react": "^16.8.2",
"react-dom": "^16.8.2",
"react": "^16.8.6",
"react-dom": "^16.8.6",
"react-modal": "^3.8.1",
"react-scripts": "2.1.5",
"react-select": "^2.4.2",
"react-swipeable": "^5.1.0",
"react-switch": "^4.1.0",
"swagger-parser": "^6.0.5",
"uuid": "^3.3.2",
"xterm": "^3.12.0",
"react-scripts": "3.0.1",
"react-select": "^3.0.4",
"react-swipeable": "^5.2.3",
"react-switch": "^5.0.0",
"swagger-parser": "^7.0.0",
"time-ago": "^0.2.1",
"xterm": "^3.14.2",
"yamljs": "^0.3.0"
},
"scripts": {
@@ -35,10 +37,10 @@
"not op_mini all"
],
"devDependencies": {
"eslint": "^5.12.0",
"eslint": "^5.16.0",
"eslint-config-airbnb": "^17.1.0",
"eslint-plugin-import": "^2.16.0",
"eslint-plugin-import": "^2.17.3",
"eslint-plugin-jsx-a11y": "^6.2.1",
"eslint-plugin-react": "^7.12.4"
"eslint-plugin-react": "^7.13.0"
}
}
+1 -3
View File
@@ -20,9 +20,7 @@ export default class Button extends Base {
render() {
const {working} = this.state || {};
const {
children, disabled, className, title,
} = this.props;
const {children, disabled, className, title} = this.props;
return (
<button
+37 -10
View File
@@ -1,5 +1,5 @@
import React from 'react';
import moment from 'moment';
import fromNow from '../utils/dates';
import {TableBody} from './listViewHelpers';
import log from '../utils/log';
import ResourceSvg from '../art/resourceSvg';
@@ -46,9 +46,9 @@ export default function EventsPanel({items, filter, shortList, sort}) {
<div className='td_iconLabel'>{x.involvedObject.kind}</div>
</td>
{!shortList && (
<td className='wrapped_name'>{x.involvedObject.namespace}:{x.involvedObject.name}</td>
<td className='wrapped_name'>{getName(x.involvedObject)}</td>
)}
<td>{moment(x.metadata.creationTimestamp).fromNow(true)}</td>
<td>{fromNow(x.metadata.creationTimestamp)}</td>
<td className='optional_small'>{x.reason}</td>
<td>{x.message}</td>
</tr>
@@ -59,23 +59,50 @@ export default function EventsPanel({items, filter, shortList, sort}) {
);
}
function getName({kind, namespace, name}) {
const text = namespace ? `${namespace}:${name}` : name;
const href = getHref(kind, namespace, name);
return href ? (<a href={href}>{text}</a>) : text;
}
function getHref(kind, namespace, name) {
switch (kind) {
case 'ClusterRole': return `/#!clusterrole/${name}`;
case 'ClusterRoleBinding': return `/#!clusterrolebinding/${name}`;
case 'ConfigMap': return `/#!configmap/${namespace}/${name}`;
case 'DaemonSet': return `/#!workload/daemonset/${namespace}/${name}`;
case 'Deployment': return `/#!workload/deployment/${namespace}/${name}`;
case 'Ingress': return `/#!ingress/${namespace}/${name}`;
case 'Node': return `/#!node/${name}`;
case 'PersistentVolume': return `/#!persistentvolume/${name}`;
case 'PersistentVolumeClaim': return `/#!persistentvolumeclaim/${namespace}/${name}`;
case 'Pod': return `/#!pod/${namespace}/${name}`;
case 'ReplicaSet': return `/#!replicaset/${namespace}/${name}`;
case 'Role': return `/#!role/${namespace}/${name}`;
case 'RoleBinding': return `/#!rolebinding/${namespace}/${name}`;
case 'Secret': return `/#!secret/${namespace}/${name}`;
case 'Service': return `/#!service/${namespace}/${name}`;
case 'ServiceAccount': return `/#!serviceaccount/${namespace}/${name}`;
case 'StatefulSet': return `/#!workload/statefulset/${namespace}/${name}`;
case 'StorageClass': return `/#!storageclass/${name}`;
default: return undefined;
}
}
function sortByName({involvedObject}) {
return `${involvedObject.namespace}:${involvedObject.name}`;
}
function getTypeClass(type) {
switch (type) {
case 'Normal':
return '';
case 'Warning':
case 'Error':
return 'svg_error';
case 'Normal': return undefined;
case 'Warning': return 'svg_warn';
case 'Error': return 'svg_error';
default: {
const error = new Error('Unexpected event type');
log.error('Unexpected event type', {error, type});
return '';
return 'svg_neutral';
}
}
}
+4 -4
View File
@@ -1,6 +1,6 @@
import _ from 'lodash';
import React from 'react';
import moment from 'moment';
import fromNow from '../utils/dates';
import Loading from './loading';
import Sorter, {sortByDate} from './sorter';
import ResourceSvg from '../art/resourceSvg';
@@ -49,13 +49,13 @@ export function MetadataHeaders({includeNamespace, sort}) {
);
}
export function MetadataColumns({item, href, includeNamespace, isError}) {
export function MetadataColumns({item, href, includeNamespace, resourceClass}) {
return (
<>
<td className='td_icon optional_small'>
<ResourceSvg
resource={item.kind}
className={isError ? 'svg_error' : undefined}
className={resourceClass}
/>
<div className='td_iconLabel'>{item.kind}</div>
</td>
@@ -70,7 +70,7 @@ export function MetadataColumns({item, href, includeNamespace, isError}) {
)}
<td className='optional_medium'>
{moment(item.metadata.creationTimestamp).fromNow(true)}
{fromNow(item.metadata.creationTimestamp)}
</td>
</>
);
+1 -2
View File
@@ -1,6 +1,5 @@
import React from 'react';
import Field from './field';
import formatDate from '../utils/dates';
import {objectMap} from './listViewHelpers';
const MetadataFields = ({item}) => (
@@ -13,7 +12,7 @@ const MetadataFields = ({item}) => (
)}
<Field name='Created'>
{formatDate(item.metadata.creationTimestamp)}
{new Date(item.metadata.creationTimestamp).toLocaleString()}
</Field>
{item.metadata.labels && (
+2 -2
View File
@@ -4,12 +4,12 @@ import Chart from './chart';
import LoadingChart from './loadingChart';
export default function NodeStatusChart({items}) {
const ready = _.filter(items, x => getReadyStatus(x) === 'True');
const readyCount = _.sumBy(items, x => getReadyStatus(x) === 'True' ? 1 : 0); // eslint-disable-line no-confusing-arrow
return (
<div className='charts_item'>
{items ? (
<Chart used={ready.length} available={items.length} />
<Chart used={readyCount} available={items.length} />
) : (
<LoadingChart />
)}
+2 -2
View File
@@ -5,12 +5,12 @@ import LoadingChart from './loadingChart';
export default function PodStatusChart({items}) {
const available = items && items.length;
const current = _.filter(items, x => x.status.phase === 'Running').length;
const count = _.sumBy(items, x => x.status.phase === 'Running' ? 1 : 0); // eslint-disable-line no-confusing-arrow
return (
<div className='charts_item'>
{items ? (
<Chart used={current} pending={available - current} available={available} />
<Chart used={count} pending={available - count} available={available} />
) : (
<LoadingChart />
)}
+20 -1
View File
@@ -74,7 +74,7 @@ export default class PodsPanel extends Base {
<tr key={x.metadata.uid}>
<MetadataColumns
item={x}
isError={x.status.phase !== 'Running'}
resourceClass={getPhaseStyle(x.status.phase)}
includeNamespace={!skipNamespace}
href={`#!pod/${x.metadata.namespace}/${x.metadata.name}`}
/>
@@ -89,6 +89,25 @@ export default class PodsPanel extends Base {
}
}
function getPhaseStyle(phase) {
switch (phase) {
case 'Pending':
return 'svg_warn';
case 'Running':
return undefined;
case 'Failed':
case 'Unknown':
return 'svg_error';
case 'Succeeded':
case 'Completed':
default:
return 'svg_neutral';
}
}
function sortBy(item, metrics, resource, type) {
const result = getPodResourcePercent(item, metrics, resource, type);
return Number.isFinite(result) ? result : -1;
+8
View File
@@ -5,6 +5,14 @@ svg {
height: 25px;
}
.svg_neutral {
fill: $color-neutral;
}
.svg_warn {
fill: $color-warn;
}
.svg_error {
fill: $color-error;
}
+3 -1
View File
@@ -5,8 +5,10 @@ $color-light: #AAA;
$color-dark: #888098;
$color-darkest: #344055;
$color-accent: #326ce5;
$color-accent: #326CE5;
$color-warn: #aaa810;
$color-error: #B9314F;
$color-neutral: $color-dark;
$color-font: $color-darkest;
+14 -7
View File
@@ -1,4 +1,5 @@
import _ from 'lodash';
import {Base64} from 'js-base64';
import {request, stream, streamResult, streamResults} from './apiProxy';
import log from '../utils/log';
@@ -157,20 +158,26 @@ function swagger() {
function exec(namespace, name, container, cb) {
const url = `/api/v1/namespaces/${namespace}/pods/${name}/exec?container=${container}&command=sh&stdin=1&stderr=1&stdout=1&tty=1`;
const protocols = ['v4.channel.k8s.io', 'v3.channel.k8s.io', 'v2.channel.k8s.io', 'channel.k8s.io'];
return stream(url, cb, false, protocols);
const additionalProtocols = ['v4.channel.k8s.io', 'v3.channel.k8s.io', 'v2.channel.k8s.io', 'channel.k8s.io'];
return stream(url, cb, {additionalProtocols, isJson: false});
}
function logs(namespace, name, container, showPrevious, cb) {
const url = `/api/v1/namespaces/${namespace}/pods/${name}/log?container=${container}&previous=${showPrevious}&tailLines=2000&follow=true`;
const {cancel} = stream(url, transformer, false);
function logs(namespace, name, container, tailLines, showPrevious, cb) {
const items = [];
const url = `/api/v1/namespaces/${namespace}/pods/${name}/log?container=${container}&previous=${showPrevious}&tailLines=${tailLines}&follow=true`;
const {cancel} = stream(url, transformer, {isJson: false, connectCb});
return cancel;
function connectCb() {
items.length = 0;
}
function transformer(item) {
if (!item) return; // For some reason, this api returns a lot of empty strings
const message = atob(item);
cb(message);
const message = Base64.decode(item);
items.push(message);
cb(items);
}
}
+13 -4
View File
@@ -1,3 +1,4 @@
import * as cookie from 'js-cookie';
import log from '../utils/log';
const {host, href, hash, search} = window.location;
@@ -6,6 +7,12 @@ const isDev = process.env.NODE_ENV !== 'production';
const BASE_HTTP_URL = isDev && host === 'localhost:4653' ? 'http://localhost:4654' : nonHashedUrl;
const BASE_WS_URL = BASE_HTTP_URL.replace('http', 'ws');
const authorizationCookie = cookie.get('Authorization');
if (authorizationCookie) {
setToken(authorizationCookie);
cookie.remove('Authorization');
}
export function getToken() {
return localStorage.authToken;
}
@@ -36,7 +43,7 @@ export async function request(path, params, autoLogoutOnAuthError = true) {
const opts = Object.assign({headers: {}}, params);
const token = getToken();
if (token) opts.headers.Authorization = `Bearer ${token}`;
if (token) opts.headers.Authorization = token;
const url = combinePath(BASE_HTTP_URL, path);
const response = await fetch(url, opts);
@@ -81,7 +88,7 @@ export async function streamResult(url, name, cb, errCb) {
const fieldSelector = encodeURIComponent(`metadata.name=${name}`);
const watchUrl = `${url}?watch=1&fieldSelector=${fieldSelector}`;
socket = stream(watchUrl, x => cb(x.object));
socket = stream(watchUrl, x => cb(x.object), {isJson: true});
} catch (err) {
log.error('Error in api request', {err, url});
if (errCb) errCb(err);
@@ -112,7 +119,7 @@ export async function streamResults(url, cb, errCb) {
add(items, kind);
const watchUrl = `${url}?watch=1&resourceVersion=${metadata.resourceVersion}`;
socket = stream(watchUrl, update);
socket = stream(watchUrl, update, {isJson: true});
} catch (err) {
log.error('Error in api request', {err, url});
if (errCb) errCb(err);
@@ -177,9 +184,10 @@ export async function streamResults(url, cb, errCb) {
}
}
export function stream(url, cb, isJson = true, additionalProtocols) {
export function stream(url, cb, args) {
let connection;
let isCancelled;
const {isJson, additionalProtocols, connectCb} = args;
connect();
@@ -195,6 +203,7 @@ export function stream(url, cb, isJson = true, additionalProtocols) {
}
function connect() {
if (connectCb) connectCb();
connection = connectStream(url, cb, onFail, isJson, additionalProtocols);
}
+3 -5
View File
@@ -1,7 +1,5 @@
import moment from 'moment';
import timeAgo from 'time-ago';
export default function formatDate(value) {
const date = moment(value);
const format = date.isSame(moment(), 'day') ? 'h:mma' : 'M/D/YY h:mma';
return date.format(format);
export default function fromNow(value) {
return timeAgo.ago(value, true);
}
+10 -6
View File
@@ -1,6 +1,5 @@
import './auth.scss';
import React from 'react';
import uuidv4 from 'uuid/v4';
import {addUserNotification} from '../components/notifier';
import {setToken, deleteToken} from '../services/apiProxy';
import api from '../services/api';
@@ -64,7 +63,7 @@ export default class Auth extends Base {
}
async function redirectToOidc(authEndpoint) {
const state = uuidv4();
const state = window.location.href;
const redirectUri = window.location.href.replace(window.location.hash, '');
sessionStorage.oidc = JSON.stringify({state, redirectUri});
@@ -88,7 +87,7 @@ async function oidcLogin(code, returnedState) {
try {
const {token} = await api.oidc.post(code, redirectUri);
login(token);
login(token, state);
} catch (err) {
log.error('OICD login failed', {err});
addUserNotification('Login failed.', true);
@@ -96,11 +95,16 @@ async function oidcLogin(code, returnedState) {
}
}
async function login(token) {
async function login(token, redirectUri) {
try {
setToken(token);
setToken(`Bearer ${token}`);
await api.testAuth();
window.location.reload();
if (redirectUri) {
window.location = redirectUri;
} else {
window.location.reload();
}
} catch (err) {
log.error('Login Failed', err);
+8 -17
View File
@@ -3,6 +3,7 @@ import _ from 'lodash';
import React from 'react';
import Switch from 'react-switch';
import Select from 'react-select';
import Ansi from 'ansi-to-react';
import Base from '../components/base';
import InputFilter from '../components/inputFilter';
import Loading from '../components/loading';
@@ -21,7 +22,7 @@ export default class Logs extends Base {
// on the trailing edge of the timeout only if the debounced function is invoked more
// than once during the wait timeout."
const options = {leading: true, trailing: true, maxWait: 1000};
this.debouncedRefreshLogs = _.debounce(this.refreshLogs.bind(this), 1000, options);
this.debouncedSetState = _.debounce(this.setState.bind(this), 100, options);
}
componentDidMount() {
@@ -51,28 +52,17 @@ export default class Logs extends Base {
startLogsStream(container, showPrevious) {
if (!container) return;
const {namespace, name} = this.props;
this.setState({container, showPrevious, items: []});
const {namespace, name} = this.props;
this.registerApi({
items: api.logs(namespace, name, container, showPrevious, items => this.onLogs(items)),
items: api.logs(namespace, name, container, 1000, showPrevious, items => this.debouncedSetState({items})), // eslint-disable-line max-len
});
}
onLogs(log) {
const {items} = this.state;
items.push(log);
this.debouncedRefreshLogs(items);
}
refreshLogs(logs) {
const items = logs.slice(-1000);
this.setState({items});
}
render() {
const {namespace, name} = this.props;
const {items, container, containers = [], filter = '', showPrevious = false} = this.state || {};
const {items, container, containers = [], filter = '', showPrevious = false} = this.state;
const lowercaseFilter = filter.toLowerCase();
const filteredLogs = items.filter(x => x.toLowerCase().includes(lowercaseFilter));
@@ -111,13 +101,14 @@ export default class Logs extends Base {
filter={filter}
onChange={x => this.setState({filter: x})}
/>
</div>
<div className='contentPanel'>
{!items ? <Loading /> : (
<pre>
{filteredLogs.join('')}
{filteredLogs.map((x, i) => (
<Ansi key={i}>{x}</Ansi>
))}
</pre>
)}
</div>
+6 -9
View File
@@ -1,6 +1,6 @@
import _ from 'lodash';
import React from 'react';
import moment from 'moment';
import fromNow from '../utils/dates';
import api from '../services/api';
import Base from '../components/base';
import ItemHeader from '../components/itemHeader';
@@ -48,11 +48,11 @@ export default class Node extends Base {
<ChartsContainer>
<div className='charts_item'>
{item ? (
<span className='charts_number'>{getUpTime(item)}</span>
<span className='charts_number'>{getUptime(item)}</span>
) : (
<LoadingChart />
)}
<div className='charts_itemLabel'>Days Up</div>
<div className='charts_itemLabel'>Uptime</div>
</div>
<NodeCpuChart items={item && [item]} metrics={metrics && [metrics]} />
<NodeRamChart items={item && [item]} metrics={metrics && [metrics]} />
@@ -97,7 +97,7 @@ export default class Node extends Base {
<tr key={x.type}>
<td>{x.type}</td>
<td>{x.status}</td>
<td className='optional_medium'>{moment(x.lastTransitionTime).fromNow()}</td>
<td className='optional_medium'>{fromNow(x.lastTransitionTime)}</td>
<td className='optional_small'>{x.reason}</td>
<td className='optional_small'>{x.message}</td>
</tr>
@@ -118,12 +118,9 @@ export default class Node extends Base {
}
}
function getUpTime({status}) {
function getUptime({status}) {
const ready = status.conditions.find(y => y.type === 'Ready');
if (!ready) return 'N/A';
const last = moment(ready.lastTransitionTime);
const diff = moment().diff(last);
const hours = moment.duration(diff).asDays();
return _.round(hours);
return fromNow(ready.lastTransitionTime);
}
+4 -1
View File
@@ -116,7 +116,10 @@ export default class Pod extends Base {
<ContainersPanel spec={item && item.spec} />
<div className='contentPanel_header'>Events</div>
<EventsPanel items={filteredEvents} />
<EventsPanel
shortList={true}
items={filteredEvents}
/>
</div>
);
}
+2 -2
View File
@@ -22,11 +22,11 @@ spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.registry }}/{{ .Values.image.name }}:{{ .Values.image.tag }}"
pullPolicy: IfNotPresent
imagePullPolicy: IfNotPresent
resources:
requests:
cpu: "25m"
memory: "100Mi"
limits:
cpu: "100m"
memory: "200Mi"
memory: "200Mi"
+2
View File
@@ -25,6 +25,8 @@ spec:
port: 4654
initialDelaySeconds: 30
timeoutSeconds: 30
nodeSelector:
'beta.kubernetes.io/os': linux
---
kind: Service
+2
View File
@@ -41,6 +41,8 @@ spec:
secretKeyRef:
name: k8dash
key: secret
nodeSelector:
'beta.kubernetes.io/os': linux
---
kind: Service
+2
View File
@@ -25,6 +25,8 @@ spec:
port: 4654
initialDelaySeconds: 30
timeoutSeconds: 30
nodeSelector:
'beta.kubernetes.io/os': linux
---
kind: Service
+13 -1
View File
@@ -44,7 +44,7 @@ const app = express();
app.disable('x-powered-by'); // for security reasons, best not to tell attackers too much about our backend
app.use(logging);
if (NODE_ENV !== 'production') app.use(cors());
app.use('/', express.static('public'));
app.use('/', preAuth, express.static('public'));
app.get('/oidc', getOidc);
app.post('/oidc', postOidc);
app.use('/*', proxy(proxySettings));
@@ -53,6 +53,18 @@ app.use(handleErrors);
http.createServer(app).listen(4654);
console.log('Server started');
function preAuth(req, res, next) {
const auth = req.header('Authorization');
// If the request already contains an authorization header, pass it through to the client (as a cookie)
if (auth) {
res.cookie('Authorization', auth, {maxAge: 60, httpOnly: false});
console.log('Authorization header found. Passing through to client.');
}
next();
}
function logging(req, res, next) {
res.once('finish', () => console.log(req.method, req.url, res.statusCode));
next();
+301 -320
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -10,16 +10,16 @@
"author": "",
"license": "ISC",
"dependencies": {
"@kubernetes/client-node": "^0.8.1",
"@kubernetes/client-node": "^0.8.2",
"cors": "^2.8.5",
"express": "^4.16.4",
"express": "^4.17.1",
"http-proxy-middleware": "^0.19.1",
"openid-client": "^2.4.5",
"openid-client": "^2.5.0",
"request": "^2.88.0",
"request-promise-native": "^1.0.7",
"stream-to-string": "^1.2.0"
},
"devDependencies": {
"nodemon": "^1.18.10"
"nodemon": "^1.19.1"
}
}