feat: list, create and start snapshots
This commit is contained in:
parent
55811372fb
commit
8cdf8070e8
@ -229,7 +229,13 @@ const resolvers = {
|
||||
startMachineFromSnapshot: (root, { id, name }) =>
|
||||
api.machines.snapshots
|
||||
.startFromSnapshot({ id, name })
|
||||
.then(() => resolvers.Query.machine(null, { id }))
|
||||
.then(() => resolvers.Query.machine(null, { id })),
|
||||
|
||||
deleteMachineSnapshot: async (root, { id, snapshot: name }) => {
|
||||
const snapshot = await api.machines.snapshots.get({ id, name });
|
||||
await api.machines.snapshots.destroy({ id, name });
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -25,6 +25,7 @@
|
||||
"lodash.isstring": "^4.0.1",
|
||||
"lodash.sortby": "^4.7.0",
|
||||
"lunr": "^2.1.3",
|
||||
"moment": "^2.19.1",
|
||||
"normalized-styled-components": "^1.0.17",
|
||||
"param-case": "^2.1.1",
|
||||
"prop-types": "^15.6.0",
|
||||
|
@ -5,3 +5,5 @@ export { default as Network } from './network';
|
||||
export { default as FirewallRule } from './firewall-rule';
|
||||
export { default as Resize } from './resize';
|
||||
export { default as CreateSnapshot } from './create-snapshot';
|
||||
export { default as Snapshots } from './snapshots';
|
||||
export { default as Snapshot } from './snapshot';
|
||||
|
56
packages/my-joy-beta/src/components/instances/snapshot.js
Normal file
56
packages/my-joy-beta/src/components/instances/snapshot.js
Normal file
@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import titleCase from 'title-case';
|
||||
import moment from 'moment';
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardMeta,
|
||||
CardAction,
|
||||
CardTitle,
|
||||
CardLabel,
|
||||
CardView,
|
||||
Checkbox,
|
||||
FormGroup,
|
||||
QueryBreakpoints,
|
||||
StatusLoader
|
||||
} from 'joyent-ui-toolkit';
|
||||
|
||||
const { SmallOnly, Small } = QueryBreakpoints;
|
||||
|
||||
const stateColor = {
|
||||
QUEUED: 'blue',
|
||||
CANCELED: 'grey',
|
||||
FAILED: 'red',
|
||||
CREATED: 'green'
|
||||
};
|
||||
|
||||
export default ({ name, state, created, loading, last, first }) => (
|
||||
<Card collapsed flat={!last} topMargin={first} bottomless={!last} gapless>
|
||||
<CardView>
|
||||
<CardMeta>
|
||||
<CardAction>
|
||||
<FormGroup name={name} reduxForm>
|
||||
<Checkbox />
|
||||
</FormGroup>
|
||||
</CardAction>
|
||||
<CardTitle>{name}</CardTitle>
|
||||
<CardLabel>{moment.unix(created).fromNow()}</CardLabel>
|
||||
{loading && (
|
||||
<CardLabel>
|
||||
<StatusLoader small />
|
||||
</CardLabel>
|
||||
)}
|
||||
{!loading && (
|
||||
<Small>
|
||||
<CardLabel color={stateColor[state]}>{titleCase(state)}</CardLabel>
|
||||
</Small>
|
||||
)}
|
||||
{!loading && (
|
||||
<SmallOnly>
|
||||
<CardLabel color={stateColor[state]} />
|
||||
</SmallOnly>
|
||||
)}
|
||||
</CardMeta>
|
||||
</CardView>
|
||||
</Card>
|
||||
);
|
162
packages/my-joy-beta/src/components/instances/snapshots.js
Normal file
162
packages/my-joy-beta/src/components/instances/snapshots.js
Normal file
@ -0,0 +1,162 @@
|
||||
import React from 'react';
|
||||
import { Row, Col } from 'react-styled-flexboxgrid';
|
||||
import forceArray from 'force-array';
|
||||
import find from 'lodash.find';
|
||||
|
||||
import {
|
||||
FormGroup,
|
||||
Input,
|
||||
FormLabel,
|
||||
ViewContainer,
|
||||
StatusLoader,
|
||||
Select,
|
||||
Message,
|
||||
MessageTitle,
|
||||
MessageDescription,
|
||||
Button,
|
||||
QueryBreakpoints
|
||||
} from 'joyent-ui-toolkit';
|
||||
|
||||
import Item from './snapshot';
|
||||
|
||||
const { SmallOnly, Medium } = QueryBreakpoints;
|
||||
|
||||
export default ({
|
||||
snapshots = [],
|
||||
selected = [],
|
||||
loading,
|
||||
error,
|
||||
handleChange = () => null,
|
||||
onAction = () => null,
|
||||
handleSubmit,
|
||||
submitting = false,
|
||||
pristine = true,
|
||||
...rest
|
||||
}) => {
|
||||
const allowedActions = {
|
||||
delete: selected.length > 0,
|
||||
start: selected.length === 1
|
||||
};
|
||||
|
||||
const handleActions = ev => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
|
||||
onAction({
|
||||
name: ev.target.value,
|
||||
items: selected
|
||||
});
|
||||
};
|
||||
|
||||
const _snapshots = forceArray(snapshots);
|
||||
|
||||
const _loading = !_snapshots.length &&
|
||||
loading && (
|
||||
<ViewContainer center>
|
||||
<StatusLoader />
|
||||
</ViewContainer>
|
||||
);
|
||||
|
||||
const items = _snapshots.map((snapshot, i, all) => {
|
||||
const { name } = snapshot;
|
||||
|
||||
const isSelected = Boolean(find(selected, ['name', name]));
|
||||
const isSubmitting = isSelected && submitting;
|
||||
|
||||
return (
|
||||
<Item
|
||||
key={name}
|
||||
{...snapshot}
|
||||
last={all.length - 1 === i}
|
||||
first={!i}
|
||||
loading={isSubmitting}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const _error = error &&
|
||||
!submitting && (
|
||||
<Message error>
|
||||
<MessageTitle>Ooops!</MessageTitle>
|
||||
<MessageDescription>{error}</MessageDescription>
|
||||
</Message>
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
onChange={() => handleSubmit(ctx => handleChange(ctx))}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<Row between="xs">
|
||||
<Col xs={8} sm={8} lg={6}>
|
||||
<Row>
|
||||
<Col xs={7} sm={7} md={6} lg={6}>
|
||||
<FormGroup name="filter" reduxForm>
|
||||
<FormLabel>Filter snapshots</FormLabel>
|
||||
<Input
|
||||
placeholder="Search for name or state"
|
||||
disabled={pristine && !items.length}
|
||||
fluid
|
||||
/>
|
||||
</FormGroup>
|
||||
</Col>
|
||||
<Col xs={5} sm={3} lg={3}>
|
||||
<FormGroup name="sort" reduxForm>
|
||||
<FormLabel>Sort</FormLabel>
|
||||
<Select disabled={!items.length} fluid>
|
||||
<option value="name">Name</option>
|
||||
<option value="state">State</option>
|
||||
<option value="created">Created</option>
|
||||
<option value="updated">Updated</option>
|
||||
</Select>
|
||||
</FormGroup>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
<Col xs={4} sm={4} lg={6}>
|
||||
<Row end="xs">
|
||||
<Col xs={6} sm={4} md={3} lg={2}>
|
||||
<FormGroup>
|
||||
<FormLabel>⁣</FormLabel>
|
||||
<Select
|
||||
value="actions"
|
||||
disabled={!items.length || !selected.length}
|
||||
onChange={handleActions}
|
||||
fluid
|
||||
>
|
||||
<option value="actions" selected disabled>
|
||||
≡
|
||||
</option>
|
||||
<option value="delete" disabled={!allowedActions.delete}>
|
||||
Delete
|
||||
</option>
|
||||
<option value="start" disabled={!allowedActions.start}>
|
||||
Start
|
||||
</option>
|
||||
</Select>
|
||||
</FormGroup>
|
||||
</Col>
|
||||
<Col xs={6} sm={6} md={5} lg={2}>
|
||||
<FormGroup>
|
||||
<FormLabel>⁣</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
small
|
||||
icon
|
||||
fluid
|
||||
onClick={() => onAction({ name: 'create' })}
|
||||
>
|
||||
<SmallOnly>+</SmallOnly>
|
||||
<Medium>Create</Medium>
|
||||
</Button>
|
||||
</FormGroup>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
{_loading}
|
||||
{_error}
|
||||
{items}
|
||||
</form>
|
||||
);
|
||||
};
|
@ -1,31 +1,50 @@
|
||||
import React from 'react';
|
||||
import ReactJson from 'react-json-view';
|
||||
import PropTypes from 'prop-types';
|
||||
import moment from 'moment';
|
||||
import forceArray from 'force-array';
|
||||
import { connect } from 'react-redux';
|
||||
import { compose, graphql } from 'react-apollo';
|
||||
import find from 'lodash.find';
|
||||
import sortBy from 'lodash.sortby';
|
||||
import get from 'lodash.get';
|
||||
|
||||
import { reduxForm, stopSubmit, startSubmit, change } from 'redux-form';
|
||||
|
||||
import {
|
||||
ViewContainer,
|
||||
Title,
|
||||
StatusLoader,
|
||||
Message,
|
||||
MessageTitle,
|
||||
MessageDescription
|
||||
} from 'joyent-ui-toolkit';
|
||||
|
||||
import GetSnapshots from '@graphql/list-snapshots.gql';
|
||||
import StartSnapshot from '@graphql/start-from-snapshot.gql';
|
||||
import RemoveSnapshot from '@graphql/remove-snapshot.gql';
|
||||
import { Snapshots as SnapshotsList } from '@components/instances';
|
||||
import GenIndex from '@state/gen-index';
|
||||
|
||||
const Snapshots = ({ snapshots = [], loading, error }) => {
|
||||
const SnapshotsListForm = reduxForm({
|
||||
form: `snapshots-list`,
|
||||
initialValues: {
|
||||
sort: 'name'
|
||||
}
|
||||
})(SnapshotsList);
|
||||
|
||||
const Snapshots = ({
|
||||
snapshots = [],
|
||||
selected = [],
|
||||
loading,
|
||||
error,
|
||||
handleAction
|
||||
}) => {
|
||||
const _title = <Title>Snapshots</Title>;
|
||||
const _loading = !(loading && !forceArray(snapshots).length) ? null : (
|
||||
<StatusLoader />
|
||||
);
|
||||
|
||||
const _summary = !_loading && <ReactJson src={snapshots} />;
|
||||
const _values = forceArray(snapshots);
|
||||
const _loading = !_values.length && loading;
|
||||
|
||||
const _error = !(error && !_loading) ? null : (
|
||||
const _error = error &&
|
||||
!_loading &&
|
||||
!_values.length && (
|
||||
<Message error>
|
||||
<MessageTitle>Ooops!</MessageTitle>
|
||||
<MessageDescription>
|
||||
@ -35,20 +54,22 @@ const Snapshots = ({ snapshots = [], loading, error }) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<ViewContainer center={Boolean(_loading)} main>
|
||||
<ViewContainer main>
|
||||
{_title}
|
||||
{_loading}
|
||||
{_error}
|
||||
{_summary}
|
||||
<SnapshotsListForm
|
||||
snapshots={_values}
|
||||
loading={_loading}
|
||||
onAction={handleAction}
|
||||
selected={selected}
|
||||
/>
|
||||
</ViewContainer>
|
||||
);
|
||||
};
|
||||
|
||||
Snapshots.propTypes = {
|
||||
loading: PropTypes.bool
|
||||
};
|
||||
|
||||
export default compose(
|
||||
graphql(StartSnapshot, { name: 'start' }),
|
||||
graphql(RemoveSnapshot, { name: 'remove' }),
|
||||
graphql(GetSnapshots, {
|
||||
options: ({ match }) => ({
|
||||
pollInterval: 1000,
|
||||
@ -56,14 +77,108 @@ export default compose(
|
||||
name: get(match, 'params.instance')
|
||||
}
|
||||
}),
|
||||
props: ({ data: { loading, error, variables, ...rest } }) => ({
|
||||
snapshots: get(
|
||||
find(get(rest, 'machines', []), ['name', variables.name]),
|
||||
props: ({ data: { loading, error, variables, ...rest } }) => {
|
||||
const { name } = variables;
|
||||
const instance = find(get(rest, 'machines', []), ['name', name]);
|
||||
|
||||
const snapshots = get(
|
||||
instance,
|
||||
'snapshots',
|
||||
[]
|
||||
),
|
||||
).map(({ created, updated, ...rest }) => ({
|
||||
...rest,
|
||||
created: moment.utc(created).unix(),
|
||||
updated: moment.utc(updated).unix()
|
||||
}));
|
||||
|
||||
const index = GenIndex(
|
||||
snapshots.map(({ name, ...rest }) => ({ ...rest, id: name }))
|
||||
);
|
||||
|
||||
return {
|
||||
index,
|
||||
snapshots,
|
||||
instance,
|
||||
loading,
|
||||
error
|
||||
};
|
||||
}
|
||||
}),
|
||||
connect(
|
||||
(state, { index, snapshots = [], ...rest }) => {
|
||||
const form = get(state, 'form.snapshots-list.values', {});
|
||||
const filter = get(form, 'filter');
|
||||
const sort = get(form, 'sort');
|
||||
|
||||
const values = filter
|
||||
? index.search(filter).map(({ ref }) => find(snapshots, ['name', ref]))
|
||||
: snapshots;
|
||||
|
||||
const selected = Object.keys(form)
|
||||
.filter(key => Boolean(form[key]))
|
||||
.map(name => find(values, ['name', name]))
|
||||
.filter(Boolean)
|
||||
.map(({ name }) => find(snapshots, ['name', name]))
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
...rest,
|
||||
snapshots: sortBy(values, value => get(value, sort)),
|
||||
selected
|
||||
};
|
||||
},
|
||||
(dispatch, { create, start, remove, instance, history, match }) => ({
|
||||
handleAction: ({ name, items = [] }) => {
|
||||
const form = 'snapshots-list';
|
||||
|
||||
const types = {
|
||||
start: () =>
|
||||
Promise.resolve(dispatch(startSubmit(form))).then(() =>
|
||||
Promise.all(
|
||||
items.map(({ name }) =>
|
||||
start({ variables: { id: instance.id, snapshot: name } })
|
||||
)
|
||||
)
|
||||
),
|
||||
delete: () =>
|
||||
Promise.resolve(dispatch(startSubmit(form))).then(() =>
|
||||
Promise.all(
|
||||
items.map(({ name }) =>
|
||||
remove({ variables: { id: instance.id, snapshot: name } })
|
||||
)
|
||||
)
|
||||
),
|
||||
create: () =>
|
||||
Promise.resolve(
|
||||
history.push(`/instances/${instance.name}/snapshots/~create`)
|
||||
)
|
||||
};
|
||||
|
||||
const handleError = error => {
|
||||
dispatch(
|
||||
stopSubmit(form, {
|
||||
_error: error.graphQLErrors
|
||||
.map(({ message }) => message)
|
||||
.join('\n')
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleSuccess = () => {
|
||||
dispatch(
|
||||
items
|
||||
.map(({ name: field }) => change(form, field, false))
|
||||
.concat([stopSubmit(form)])
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
types[name] &&
|
||||
types[name]()
|
||||
.then(handleSuccess)
|
||||
.catch(handleError)
|
||||
);
|
||||
}
|
||||
})
|
||||
)
|
||||
)(Snapshots);
|
||||
|
@ -5,6 +5,8 @@ query instance($name: String!) {
|
||||
snapshots {
|
||||
name
|
||||
state
|
||||
created
|
||||
updated
|
||||
}
|
||||
}
|
||||
}
|
||||
|
5
packages/my-joy-beta/src/graphql/remove-snapshot.gql
Normal file
5
packages/my-joy-beta/src/graphql/remove-snapshot.gql
Normal file
@ -0,0 +1,5 @@
|
||||
mutation deleteMachineSnapshot($id: ID!, $snapshot: ID!) {
|
||||
deleteMachineSnapshot(id: $id, snapshot: $snapshot) {
|
||||
name
|
||||
}
|
||||
}
|
@ -1,3 +1,5 @@
|
||||
mutation startInstanceFromSnapshot($id: ID!, $snapshot: ID!) {
|
||||
startMachineFromSnapshot(id: $id, snapshot: $snapshot)
|
||||
startMachineFromSnapshot(id: $id, snapshot: $snapshot) {
|
||||
id
|
||||
}
|
||||
}
|
||||
|
@ -112,7 +112,7 @@ export default () => (
|
||||
component={InstanceCreateSnapshot}
|
||||
/>
|
||||
<Route
|
||||
path="/instances/:instance/:section?/~create-snapshot"
|
||||
path="/instances/:instance/snapshots/~create"
|
||||
exact
|
||||
component={InstanceCreateSnapshot}
|
||||
/>
|
||||
|
@ -6,18 +6,18 @@ import pascalCase from 'pascal-case';
|
||||
|
||||
export const breakpoints = {
|
||||
small: {
|
||||
upper: 768
|
||||
upper: 767
|
||||
},
|
||||
medium: {
|
||||
upper: 1024,
|
||||
lower: 769
|
||||
upper: 1023,
|
||||
lower: 768
|
||||
},
|
||||
large: {
|
||||
upper: 1200,
|
||||
lower: 1025
|
||||
upper: 1199,
|
||||
lower: 1024
|
||||
},
|
||||
xlarge: {
|
||||
lower: 1201
|
||||
lower: 1200
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -17,8 +17,9 @@ const style = css`
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
margin: 0;
|
||||
padding: ${remcalc(15)} ${remcalc(18)};
|
||||
margin-bottom: ${remcalc(8)};
|
||||
margin-top: ${remcalc(8)};
|
||||
padding: ${remcalc(13)} ${remcalc(18)};
|
||||
position: relative;
|
||||
|
||||
${typography.normal};
|
||||
@ -154,8 +155,7 @@ const style = css`
|
||||
`};
|
||||
|
||||
${is('small')`
|
||||
padding: ${remcalc(9)} ${remcalc(18)};
|
||||
font-weight: 600;
|
||||
padding: ${remcalc(13)} ${remcalc(18)};
|
||||
`};
|
||||
|
||||
${is('icon')`
|
||||
@ -167,6 +167,10 @@ const style = css`
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
`};
|
||||
|
||||
${is('marginless')`
|
||||
margin: 0;
|
||||
`};
|
||||
`;
|
||||
|
||||
const StyledButton = NButton.extend`
|
||||
|
Loading…
Reference in New Issue
Block a user