Skip to content

Commit 85e16bf

Browse files
authored
[dy] Add frontend support for managing instances (mage-ai#1165)
* [dy] Add frontend support for managing instances * [dy] Update manage page * [dy] Update * [dy] Update variables * [dy] Clean up PR * [dy] Address comments
1 parent 111ad0b commit 85e16bf

17 files changed

Lines changed: 671 additions & 42 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ dmypy.json
132132
# data files
133133
mage_ai/server/data/files/**
134134
default_repo
135+
instance_metadata.json
135136

136137
# test notebook
137138
test.ipynb

docker-compose.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@ services:
55
build:
66
context: .
77
dockerfile: ./dev.Dockerfile
8-
command: "python mage_ai/server/server.py --host ${HOST} --port ${PORT} --project ${PROJECT}"
8+
command: "python mage_ai/server/server.py --host ${HOST} --port ${PORT} --project ${PROJECT} --manage-instance ${MANAGE_INSTANCE}"
99
environment:
1010
- AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID
1111
- AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY
12+
- ECS_CLUSTER_NAME=$ECS_CLUSTER_NAME
13+
- ECS_TASK_DEFINITION=$ECS_TASK_DEFINITION
14+
- ECS_CONTAINER_NAME=$ECS_CONTAINER_NAME
1215
ports:
1316
- 6789:6789
1417
volumes:

mage_ai/cli/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ def main():
5757
parser.add_argument('repo_path', metavar='project_path', type=str)
5858
parser.add_argument('--host', nargs='?', type=str)
5959
parser.add_argument('--port', nargs='?', type=int)
60+
parser.add_argument('--manage-instance', nargs='?', type=str)
6061

6162
args = dict()
6263
if len(sys.argv) >= 3:
@@ -67,6 +68,7 @@ def main():
6768

6869
start_server(
6970
host=args.get('host'),
71+
manage=args.get('manage_instance') == '1',
7072
port=args.get('port'),
7173
project=repo_path,
7274
)
Lines changed: 88 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,87 @@
11
from botocore.config import Config
2+
from functools import reduce
23
from mage_ai.services.aws.ecs.config import EcsConfig
3-
from mage_ai.services.aws.ecs.ecs import list_tasks, run_task
4+
from mage_ai.services.aws.ecs.ecs import list_tasks, run_task, stop_task
45
from mage_ai.shared.array import find
56
from mage_ai.shared.hash import dig
6-
from typing import List
7+
from typing import Dict, List
78

89
import boto3
10+
import json
911
import os
1012

11-
12-
CLUSTER_NAME = 'mage-data-prep-development-cluster'
13-
1413
class EcsTaskManager:
15-
def __init__(self, cluster_name=CLUSTER_NAME):
14+
def __init__(self, cluster_name):
1615
self.cluster_name = cluster_name
1716

17+
self.metadata_file = os.path.join(
18+
os.getcwd(),
19+
'instance_metadata.json',
20+
)
21+
22+
if not os.path.exists(self.metadata_file):
23+
self.instance_metadata = {}
24+
25+
@property
26+
def instance_metadata(self):
27+
metadata = {}
28+
with open(self.metadata_file, 'r', encoding='utf-8') as file:
29+
metadata = json.load(file)
30+
return metadata
31+
32+
@instance_metadata.setter
33+
def instance_metadata(self, metadata):
34+
with open(self.metadata_file, 'w', encoding='utf-8') as file:
35+
json.dump(metadata, file)
36+
1837
def list_tasks(self):
1938
region_name = os.getenv('AWS_REGION_NAME', 'us-west-2')
2039
config = Config(region_name=region_name)
2140
ec2_client = boto3.client('ec2', config=config)
22-
response = list_tasks(self.cluster_name)['tasks']
2341

42+
response = list_tasks(self.cluster_name)['tasks']
2443
network_interfaces = self.__get_network_interfaces(response, ec2_client)
2544

2645
tasks = []
27-
28-
for index, task in enumerate(response):
29-
public_ip = dig(network_interfaces[index], 'Association.PublicIp')
46+
for task in response:
47+
public_ip = dig(network_interfaces.get(task['taskArn']), 'Association.PublicIp')
3048

3149
tags = task['tags']
3250
name = find(lambda tag: tag.get('key') == 'name', tags)
3351

3452
tasks.append(dict(
3553
ip=public_ip,
36-
group=task['group'],
3754
name=name.get('value') if name is not None else None,
3855
status=task['lastStatus'],
56+
task_arn=task['taskArn'],
3957
type=task['launchType'],
4058
))
4159

42-
return tasks
60+
running_instance_names = set(map(lambda x: x['name'], tasks))
61+
62+
stopped_instance_names = \
63+
[name for name in list(self.instance_metadata.keys()) if name not in running_instance_names]
64+
stopped_instances = \
65+
list(
66+
map(
67+
lambda name: { 'name': name, 'status': 'STOPPED' },
68+
stopped_instance_names
69+
)
70+
)
71+
72+
return tasks + stopped_instances
4373

4474
def create_task(self, name: str, task_definition: str, container_name: str):
4575
region_name = os.getenv('AWS_REGION_NAME', 'us-west-2')
4676
config = Config(region_name=region_name)
4777
ec2_client = boto3.client('ec2', config=config)
4878

4979
# create new task
50-
task = list_tasks(self.cluster_name)['tasks'][0]
51-
network_interface = self.__get_network_interfaces([task], ec2_client)[0]
80+
task = find(
81+
lambda task: task.get('lastStatus') == 'RUNNING',
82+
list_tasks(self.cluster_name)['tasks'],
83+
)
84+
network_interface = self.__get_network_interfaces([task], ec2_client)[task['taskArn']]
5285

5386
subnets = [network_interface['SubnetId']]
5487
security_groups = [g['GroupId'] for g in network_interface['Groups']]
@@ -67,19 +100,56 @@ def create_task(self, name: str, task_definition: str, container_name: str):
67100
],
68101
)
69102

103+
self.instance_metadata = {
104+
**self.instance_metadata,
105+
name: dict()
106+
}
107+
70108
return run_task(f'mage start {name}', ecs_config=ecs_config)
71109

72-
def __get_network_interface_id(self, task: str):
110+
def stop_task(self, task_arn: str):
111+
return stop_task(task_arn, self.cluster_name)
112+
113+
def delete_task(self, name, task_arn: str = None):
114+
if task_arn:
115+
self.stop_task(task_arn)
116+
117+
updated_metadata = self.instance_metadata
118+
119+
if name in updated_metadata:
120+
del updated_metadata[name]
121+
self.instance_metadata = updated_metadata
122+
123+
def __get_network_interface_id(self, task):
124+
if task.get('lastStatus') != 'RUNNING':
125+
return None
126+
73127
attachment = \
74128
find(lambda a: a['type'] == 'ElasticNetworkInterface', task.get('attachments', []))
75129
network_interface = \
76130
find(lambda d: d['name'] == 'networkInterfaceId', attachment.get('details', []))
77131
return network_interface.get('value', None)
78132

133+
def __get_network_interfaces(self, tasks: List, ec2_client) -> Dict:
134+
task_mapping = dict()
135+
for task in tasks:
136+
nii = self.__get_network_interface_id(task)
137+
if nii is not None:
138+
task_mapping[task['taskArn']] = nii
79139

80-
def __get_network_interfaces(self, tasks: List, ec2_client):
81-
network_interface_ids = [self.__get_network_interface_id(task) for task in tasks]
140+
network_interface_ids = list(task_mapping.values())
82141

83-
return ec2_client.describe_network_interfaces(
142+
network_interfaces = ec2_client.describe_network_interfaces(
84143
NetworkInterfaceIds=network_interface_ids
85144
)['NetworkInterfaces']
145+
146+
def aggregate(obj, task):
147+
task_arn = task['taskArn']
148+
if task_arn in task_mapping:
149+
obj[task_arn] = find(
150+
lambda i: i['NetworkInterfaceId'] == task_mapping[task_arn],
151+
network_interfaces,
152+
)
153+
return obj
154+
155+
return reduce(aggregate, tasks, {})
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
REPO_PATH_ENV_VAR = 'MAGE_REPO_PATH'
1+
REPO_PATH_ENV_VAR = 'MAGE_REPO_PATH'
2+
MANAGE_ENV_VAR = 'MAGE_IS_MANAGE_INSTANCE'

mage_ai/frontend/api/index.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export const FILE_CONTENTS: 'file_contents' = 'file_contents';
3535
export const FILES: 'files' = 'files';
3636
export const FEATURE_SETS: 'feature_sets' = 'feature_sets';
3737
export const FEATURE_SET_VERSIONS: 'feature_set_versions' = 'feature_set_versions';
38+
export const INSTANCES: 'instances' = 'instances';
3839
export const KERNELS: 'kernels' = 'kernels';
3940
export const KERNEL_ACTION_INTERRUPT: 'interrupt' = 'interrupt';
4041
export const KERNEL_ACTION_RESTART: 'restart' = 'restart';
@@ -69,6 +70,7 @@ const RESOURCES: any[][] = [
6970
[FEATURE_SETS],
7071
[FILES],
7172
[FILE_CONTENTS],
73+
[INSTANCES, CLUSTERS],
7274
[KERNELS],
7375
[KERNEL_ACTION_INTERRUPT, KERNELS],
7476
[KERNEL_ACTION_RESTART, KERNELS],
@@ -155,8 +157,8 @@ RESOURCES.forEach(([resource, parentResource, grandchildResource, swrOptions]) =
155157
apis[resource][parentResource].useUpdate = (parentId: string, id: string, opts?: any) => async (body: any) =>
156158
fetchUpdateWithParent(resource, parentResource, parentId, id, body, opts);
157159

158-
apis[resource][parentResource].useDelete = (parentId: string, id: string) => async () => {
159-
const response = await useDeleteWithParent(resource, parentResource, parentId, id);
160+
apis[resource][parentResource].useDelete = (parentId: string, id: string, query?: object) => async () => {
161+
const response = await useDeleteWithParent(resource, parentResource, parentId, id, query);
160162

161163
return await handle(response);
162164
},
@@ -208,8 +210,8 @@ RESOURCES.forEach(([resource, parentResource, grandchildResource, swrOptions]) =
208210
apis[resource].useCreate = (opts?: any) =>
209211
async (body: any) => fetchCreate(resource, body, opts);
210212

211-
apis[resource].useDelete = (id: string) => async () => {
212-
const response = await useDelete(resource, id);
213+
apis[resource].useDelete = (id: string, query?: object) => async () => {
214+
const response = await useDelete(resource, id, query);
213215

214216
return await handle(response);
215217
},

mage_ai/frontend/api/utils/use.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,16 +115,17 @@ export function useDetailWithParent(
115115
};
116116
}
117117

118-
export function useDelete(resource: string, id: string) {
119-
return buildFetch(buildUrl(resource, id), { method: DELETE });
118+
export function useDelete(resource: string, id: string, query: object = {}) {
119+
return buildFetch(buildUrl(resource, id), { query, method: DELETE });
120120
}
121121

122122
export function useDeleteWithParent(resource: string,
123123
parentResource: string,
124124
parentId: string,
125125
id: string,
126+
query: object = {},
126127
) {
127-
return buildFetch(buildUrl(parentResource, parentId, resource, id), { method: DELETE });
128+
return buildFetch(buildUrl(parentResource, parentId, resource, id), { query, method: DELETE });
128129
}
129130

130131
export function useList(

mage_ai/frontend/components/Dashboard/index.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,9 +127,11 @@ function Dashboard({
127127
/>
128128

129129
<ContainerStyle>
130-
<VerticalNavigationStyle>
131-
<VerticalNavigation navigationItems={navigationItems} />
132-
</VerticalNavigationStyle>
130+
{navigationItems?.length !== 0 && (
131+
<VerticalNavigationStyle>
132+
<VerticalNavigation navigationItems={navigationItems} />
133+
</VerticalNavigationStyle>
134+
)}
133135

134136
<Flex
135137
flex={1}

mage_ai/frontend/components/PipelineDetail/Runs/Table.style.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,15 @@ export const PopupContainerStyle = styled.div<any>`
3030
background-color: ${(props.theme.interactive || dark.interactive).defaultBackground};
3131
`}
3232
33+
${props => props.leftOffset && `
34+
left: ${props.leftOffset}px;
35+
`}
36+
37+
${props => props.topOffset && `
38+
top: ${props.topOffset}px;
39+
`}
40+
41+
${props => props.width && `
42+
width: ${props.width}px;
43+
`}
3344
`;

mage_ai/frontend/pages/index.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,26 @@
11
import { useEffect } from 'react';
22
import { useRouter } from 'next/router';
33

4+
import api from '@api';
5+
46
const Home = () => {
57
const router = useRouter();
68
const completePath = router.asPath;
79
const basePath = completePath.split('?')[0];
810

11+
const { data: dataStatus } = api.status.list();
12+
const manage = dataStatus?.status?.['is_instance_manager'];
13+
914
let pathname = completePath;
1015
if (basePath === '/') {
11-
pathname = '/pipelines';
16+
pathname = manage ? '/manage' : '/pipelines';
1217
}
1318

1419
useEffect(() => {
15-
router.replace(pathname);
16-
}, []);
20+
if (dataStatus) {
21+
router.replace(pathname);
22+
}
23+
}, [dataStatus]);
1724
};
1825

1926
export default Home;

0 commit comments

Comments
 (0)