Compare commits

..

6 Commits

Author SHA1 Message Date
xue jiahao
a3375c4526 some refactoring and make highlighter change when app changed 2024-11-11 15:26:52 +08:00
xue jiahao
1028327a37 add app management page 2024-11-11 15:26:52 +08:00
方 柏
f5b5607297 Merged PR 2: APP バージョン 履歴管理
- python3.12.4
- add app table
- flow domainid->domainurl add app flowhistory
2024-11-11 07:03:49 +00:00
dd814993f1 flow domainid->domainurl add app flowhistory 2024-11-09 15:56:13 +09:00
9dce750ee5 add app table 2024-11-05 15:35:40 +09:00
2ffa1d9438 python3.12.4 2024-11-05 12:01:22 +09:00
16 changed files with 367 additions and 85 deletions

4
.gitignore vendored
View File

@@ -1,3 +1,7 @@
.vscode
.mypy_cache
docker-stack.yml
backend/pyvenv.cfg
backend/Include/
backend/Scripts/

View File

@@ -1,4 +1,5 @@
from fastapi import Query, Request,Depends, APIRouter, UploadFile,HTTPException,File
from app.core.operation import log_operation
from app.db import Base,engine
from app.db.session import get_db
from app.db.crud import *
@@ -7,8 +8,69 @@ from typing import List, Optional
from app.core.auth import get_current_active_user,get_current_user
from app.core.apiexception import APIException
import httpx
import app.core.config as config
platform_router = r = APIRouter()
@r.get(
"/apps",
response_model=List[AppList],
response_model_exclude_none=True,
)
async def apps_list(
request: Request,
user = Depends(get_current_user),
db=Depends(get_db),
):
try:
platformapps = get_apps(db)
domain = get_activedomain(db, user.id)
kintoneevn = config.KINTONE_ENV(domain)
headers={config.API_V1_AUTH_KEY:kintoneevn.API_V1_AUTH_VALUE}
url = f"{kintoneevn.BASE_URL}{config.API_V1_STR}/apps.json"
offset = 0
limit = 100
all_apps = []
while True:
r = httpx.get(f"{url}?limit={limit}&offset={offset}", headers=headers)
json_data = r.json()
apps = json_data.get("apps",[])
all_apps.extend(apps)
if len(apps)<limit:
break
offset += limit
tempapps = platformapps.copy()
for papp in tempapps:
exist = False
for kapp in all_apps:
if kapp['appId'] == papp.appid:
exist = True
break
if not exist:
platformapps.remove(papp)
return platformapps
except Exception as e:
raise APIException('platform:apps',request.url._url,f"Error occurred while get apps:",e)
@r.post("/apps", response_model=AppList, response_model_exclude_none=True)
async def apps_update(
request: Request,
app: AppVersion,
user=Depends(get_current_user),
db=Depends(get_db),
):
try:
return update_appversion(db, app,user.id)
except Exception as e:
raise APIException('platform:apps',request.url._url,f"Error occurred while get create app :",e)
@r.get(
"/appsettings/{id}",
response_model=App,
@@ -129,7 +191,7 @@ async def flow_list(
try:
domain = get_activedomain(db, user.id)
print("domain=>",domain)
flows = get_flows_by_app(db, domain.id, appid)
flows = get_flows_by_app(db, domain.url, appid)
return flows
except Exception as e:
raise APIException('platform:flow',request.url._url,f"Error occurred while get flow by appid:",e)
@@ -144,7 +206,7 @@ async def flow_create(
):
try:
domain = get_activedomain(db, user.id)
return create_flow(db, domain.id, flow)
return create_flow(db, domain.url, flow)
except Exception as e:
raise APIException('platform:flow',request.url._url,f"Error occurred while create flow:",e)

View File

@@ -5,7 +5,7 @@ import base64
PROJECT_NAME = "KintoneAppBuilder"
#SQLALCHEMY_DATABASE_URI = "postgres://kabAdmin:P@ssw0rd!@kintonetooldb.postgres.database.azure.com/dev"
SQLALCHEMY_DATABASE_URI = "postgres://kabAdmin:P@ssw0rd!@kintonetooldb.postgres.database.azure.com/postgres"
SQLALCHEMY_DATABASE_URI = f"postgresql+psycopg2://kabAdmin:P%40ssw0rd!@kintonetooldb.postgres.database.azure.com/dev_v2"
#SQLALCHEMY_DATABASE_URI = "postgres://kabAdmin:P@ssw0rd!@kintonetooldb.postgres.database.azure.com/test"
#SQLALCHEMY_DATABASE_URI = "postgres://kabAdmin:P@ssw0rd!@ktune-prod-db.postgres.database.azure.com/postgres"
API_V1_STR = "/k/v1"

View File

@@ -69,6 +69,46 @@ def edit_user(
db.refresh(db_user)
return db_user
def get_apps(
db: Session
) -> t.List[schemas.AppList]:
return db.query(models.App).all()
def update_appversion(db: Session, appedit: schemas.AppVersion,userid:int):
app = db.query(models.App).filter(and_(models.App.domainurl == appedit.domainurl,models.App.appid == appedit.appid)).first()
if app:
app.version = app.version + 1
db_app = app
appver = app.version
else:
appver = 1
db_app = models.App(
domainurl = appedit.domainurl,
appid=appedit.appid,
appname=appedit.appname,
version = 1,
updateuser= userid
)
db.add(db_app)
flows = db.query(models.Flow).filter(and_(models.Flow.domainurl == appedit.domainurl,models.App.appid == appedit.appid))
for flow in flows:
db_flowhistory = models.FlowHistory(
flowid = flow.flowid,
appid = flow.appid,
eventid = flow.eventid,
domainurl = flow.domainurl,
name = flow.name,
content = flow.content,
createuser = userid,
version = appver
)
db.add(db_flowhistory)
db.commit()
db.refresh(db_app)
return db_app
def get_appsetting(db: Session, id: int):
app = db.query(models.AppSetting).get(id)
@@ -125,12 +165,12 @@ def get_actions(db: Session):
return actions
def create_flow(db: Session, domainid: int, flow: schemas.FlowBase):
def create_flow(db: Session, domainurl: str, flow: schemas.FlowBase):
db_flow = models.Flow(
flowid=flow.flowid,
appid=flow.appid,
eventid=flow.eventid,
domainid=domainid,
domainurl=domainurl,
name=flow.name,
content=flow.content
)
@@ -177,8 +217,8 @@ def get_flow(db: Session, flowid: str):
raise HTTPException(status_code=404, detail="Data not found")
return flow
def get_flows_by_app(db: Session, domainid: int, appid: str):
flows = db.query(models.Flow).filter(and_(models.Flow.domainid == domainid,models.Flow.appid == appid)).all()
def get_flows_by_app(db: Session,domainurl: str, appid: str):
flows = db.query(models.Flow).filter(and_(models.Flow.domainurl == domainurl,models.Flow.appid == appid)).all()
if not flows:
raise Exception("Data not found")
return flows

View File

@@ -1,5 +1,6 @@
from sqlalchemy import Boolean, Column, Integer, String, DateTime,ForeignKey
from sqlalchemy.ext.declarative import as_declarative
from sqlalchemy.orm import relationship
from datetime import datetime
from app.core.security import chacha20Decrypt
@@ -20,6 +21,16 @@ class User(Base):
is_active = Column(Boolean, default=True)
is_superuser = Column(Boolean, default=False)
class App(Base):
__tablename__ = "app"
domainurl = Column(String(200), nullable=False)
appname = Column(String(200), nullable=False)
appid = Column(String(100), index=True, nullable=False)
version = Column(Integer)
updateuser = Column(Integer,ForeignKey("user.id"))
user = relationship('User')
class AppSetting(Base):
__tablename__ = "appsetting"
@@ -51,10 +62,22 @@ class Flow(Base):
flowid = Column(String(100), index=True, nullable=False)
appid = Column(String(100), index=True, nullable=False)
eventid = Column(String(100), index=True, nullable=False)
domainid = Column(Integer,ForeignKey("domain.id"))
domainurl = Column(String(200))
name = Column(String(200))
content = Column(String)
class FlowHistory(Base):
__tablename__ = "flowhistory"
flowid = Column(String(100), index=True, nullable=False)
appid = Column(String(100), index=True, nullable=False)
eventid = Column(String(100), index=True, nullable=False)
domainurl = Column(String(200))
name = Column(String(200))
content = Column(String)
createuser = Column(Integer,ForeignKey("user.id"))
version = Column(Integer)
class Tenant(Base):
__tablename__ = "tenant"
@@ -108,6 +131,17 @@ class ErrorLog(Base):
location = Column(String(500))
content = Column(String(5000))
class OperationLog(Base):
__tablename__ = "operationlog"
tenantid = Column(String(100))
domainurl = Column(String(200))
userid = Column(Integer,ForeignKey("user.id"))
operation = Column(String(200))
function = Column(String(200))
detail = Column(String(200))
user = relationship('User')
class KintoneFormat(Base):
__tablename__ = "kintoneformat"

View File

@@ -28,21 +28,21 @@ class UserCreate(UserBase):
is_active:bool
is_superuser:bool
class Config:
class ConfigDict:
orm_mode = True
class UserEdit(UserBase):
password: t.Optional[str] = None
class Config:
class ConfigDict:
orm_mode = True
class User(UserBase):
id: int
class Config:
class ConfigDict:
orm_mode = True
@@ -50,6 +50,17 @@ class Token(BaseModel):
access_token: str
token_type: str
class AppList(Base):
domainurl: str
appname: str
appid:str
version:int
user:UserOut
class AppVersion(BaseModel):
domainurl: str
appname: str
appid:str
class TokenData(BaseModel):
id:int = 0
@@ -68,7 +79,7 @@ class AppBase(BaseModel):
class App(AppBase):
id: int
class Config:
class ConfigDict:
orm_mode = True
@@ -79,7 +90,7 @@ class Kintone(BaseModel):
desc: str = None
content: str = None
class Config:
class ConfigDict:
orm_mode = True
class Action(BaseModel):
@@ -92,7 +103,7 @@ class Action(BaseModel):
categoryid: int = None
nosort: int
categoryname : str =None
class Config:
class ConfigDict:
orm_mode = True
class FlowBase(BaseModel):
@@ -107,11 +118,11 @@ class Flow(Base):
flowid: str
appid: str
eventid: str
domainid: int
domainurl: str
name: str = None
content: str = None
class Config:
class ConfigDict:
orm_mode = True
class DomainBase(BaseModel):
@@ -133,7 +144,7 @@ class Domain(Base):
url: str
kintoneuser: str
kintonepwd: str
class Config:
class ConfigDict:
orm_mode = True
class Event(Base):
@@ -145,7 +156,7 @@ class Event(Base):
mobile: bool
eventgroup: bool
class Config:
class ConfigDict:
orm_mode = True
class ErrorCreate(BaseModel):

Binary file not shown.

View File

@@ -4,7 +4,6 @@
tag="a"
:target="target?target:'_blank'"
:href="link"
:disable="disable"
v-if="!isSeparator"
>
<q-item-section
@@ -34,7 +33,6 @@ export interface EssentialLinkProps {
icon?: string;
isSeparator?: boolean;
target?:string;
disable?:boolean;
}
withDefaults(defineProps<EssentialLinkProps>(), {
caption: '',

View File

@@ -7,18 +7,14 @@
<q-icon v-if="prop.node.eventId" name="play_circle" :color="prop.node.hasFlow ? 'green' : 'grey'" size="16px"
class="q-mr-sm">
</q-icon>
<div class="no-wrap"
:class="selectedEvent && prop.node.eventId === selectedEvent.eventId ? 'selected-node' : ''">{{prop.node.label }}
</div>
<div class="no-wrap" :class="getSelectedClass(prop.node)">{{ prop.node.label }}</div>
<q-space></q-space>
<!-- <q-icon v-if="prop.node.hasFlow" name="delete" color="negative" size="16px" class="q-mr-sm"></q-icon> -->
</div>
</template>
<template v-slot:header-CHANGE="prop">
<div class="row col items-start no-wrap event-node">
<div class="no-wrap"
:class="selectedEvent && prop.node.eventId === selectedEvent.eventId ? 'selected-node' : ''"
>{{ prop.node.label }}</div>
<div class="no-wrap" :class="getSelectedClass(prop.node)">{{ prop.node.label }}</div>
<q-space></q-space>
<q-icon name="add_circle" color="primary" size="16px" class="q-mr-sm"
@click="addChangeEvent(prop.node)"></q-icon>
@@ -27,7 +23,7 @@
<template v-slot:header-DELETABLE="prop">
<div class="row col items-start event-node" @click="onSelected(prop.node)">
<q-icon v-if="prop.node.eventId" name="play_circle" :color="prop.node.hasFlow ? 'green' : 'grey'" size="16px" class="q-mr-sm" />
<div class="no-wrap" :class="selectedEvent && prop.node.eventId === selectedEvent.eventId ? 'selected-node' : ''" >{{ prop.node.label}}</div>
<div class="no-wrap" :class="getSelectedClass(prop.node)">{{ prop.node.label }}</div>
<q-space></q-space>
<q-icon name="delete_forever" color="negative" size="16px" @click="deleteEvent(prop.node)"></q-icon>
</div>
@@ -42,7 +38,7 @@
import { QTree, useQuasar } from 'quasar';
import { ActionFlow, RootAction } from 'src/types/ActionTypes';
import { useFlowEditorStore } from 'stores/flowEditor';
import { defineComponent, ref,watchEffect } from 'vue';
import { defineComponent, ref, watchEffect } from 'vue';
import { IKintoneEvent, IKintoneEventGroup, IKintoneEventNode, kintoneEvent } from '../../types/KintoneEvents';
import FieldSelect from '../FieldSelect.vue';
import ShowDialog from '../ShowDialog.vue';
@@ -80,6 +76,11 @@ export default defineComponent({
const isFieldChange = (node: IKintoneEventNode) => {
return node.header == 'EVENT' && node.eventId.indexOf(".change.") > -1;
}
const getSelectedClass = (node: IKintoneEventNode) => {
return store.selectedEvent && node.eventId === store.selectedEvent.eventId ? 'selected-node' : '';
};
//フィールド値変更イベント追加
const closeDg = (val: string) => {
if (val == 'OK') {
@@ -132,7 +133,7 @@ export default defineComponent({
const screen = store.eventTree.findEventById(node.parentId);
let flow = store.findFlowByEventId(node.eventId);
let screenName = screen !== null ? screen.label : "";
let screenName = screen !== null ? screen.label : '';
let nodeLabel = node.label;
// if(isFieldChange(node)){
// screenName=nodeLabel;
@@ -159,6 +160,7 @@ export default defineComponent({
tree,
showDialog,
isFieldChange,
getSelectedClass,
onSelected,
selectedEvent,
addChangeEvent,

View File

@@ -22,8 +22,6 @@
<div v-if="isAdmin()">
<EssentialLink v-for="link in adminLinks" :key="link.title" v-bind="link" />
</div>
<EssentialLink v-for="link in domainLinks" :key="link.title" v-bind="link" />
</q-list>
</q-drawer>
@@ -34,7 +32,7 @@
</template>
<script setup lang="ts">
import { onMounted, computed } from 'vue';
import { onMounted } from 'vue';
import EssentialLink, { EssentialLinkProps } from 'components/EssentialLink.vue';
import DomainSelector from 'components/DomainSelector.vue';
import { useAuthStore } from 'stores/useAuthStore';
@@ -47,16 +45,21 @@ const essentialLinks: EssentialLinkProps[] = [
caption: '設計書から導入する',
icon: 'home',
link: '/',
target: '_self',
disable: !authStore.hasDomain,
target: '_self'
},
// {
// title: 'フローエディター',
// caption: 'イベントを設定する',
// icon: 'account_tree',
// link: '/#/FlowChart',
// target: '_self'
// },
{
title: 'フローエディター',
caption: 'イベントを設定する',
icon: 'account_tree',
link: '/#/FlowChart',
target: '_self',
disable: !authStore.hasDomain,
title: 'アプリ管理',
caption: 'アプリを管理する',
icon: 'widgets',
link: '/#/app',
target: '_self'
},
// {
// title: '条件エディター',
@@ -87,23 +90,58 @@ const essentialLinks: EssentialLinkProps[] = [
// link:'https://cybozu.dev/ja/kintone/docs/',
// icon:'help_outline'
// },
];
const domainLinks: EssentialLinkProps[] = [
{
title: 'ドメイン管理',
caption: 'kintoneのドメイン設定',
icon: 'domain',
link: '/#/domain',
target: '_self'
},
// {
// title: 'ドメイン適用',
// caption: 'ユーザー使用可能なドメインの設定',
// icon: 'assignment_ind',
// link: '/#/userDomain',
// target: '_self'
// title:'',
// isSeparator:true
// },
// {
// title: 'Docs',
// caption: 'quasar.dev',
// icon: 'school',
// link: 'https://quasar.dev'
// },
// {
// title: 'Icons',
// caption: 'Material Icons',
// icon: 'insert_emoticon',
// link: 'https://fonts.google.com/icons?selected=Material+Icons:insert_emoticon:'
// },
// {
// title: 'Github',
// caption: 'github.com/quasarframework',
// icon: 'code',
// link: 'https://github.com/quasarframework'
// },
// {
// title: 'Discord Chat Channel',
// caption: 'chat.quasar.dev',
// icon: 'chat',
// link: 'https://chat.quasar.dev'
// },
// {
// title: 'Forum',
// caption: 'forum.quasar.dev',
// icon: 'record_voice_over',
// link: 'https://forum.quasar.dev'
// },
// {
// title: 'Twitter',
// caption: '@quasarframework',
// icon: 'rss_feed',
// link: 'https://twitter.quasar.dev'
// },
// {
// title: 'Facebook',
// caption: '@QuasarFramework',
// icon: 'public',
// link: 'https://facebook.quasar.dev'
// },
// {
// title: 'Quasar Awesome',
// caption: 'Community Quasar projects',
// icon: 'favorite',
// link: 'https://awesome.quasar.dev'
// }
];
const adminLinks: EssentialLinkProps[] = [
@@ -114,6 +152,20 @@ const adminLinks: EssentialLinkProps[] = [
link: '/#/user',
target: '_self'
},
{
title: 'ドメイン管理',
caption: 'kintoneのドメイン設定',
icon: 'domain',
link: '/#/domain',
target: '_self'
},
{
title: 'ドメイン適用',
caption: 'ユーザー使用可能なドメインの設定',
icon: 'assignment_ind',
link: '/#/userDomain',
target: '_self'
},
]
const version = process.env.version;

View File

@@ -0,0 +1,93 @@
<template>
<div class="q-pa-md">
<div class="q-gutter-sm row items-start">
<q-breadcrumbs>
<q-breadcrumbs-el icon="widgets" label="アプリ管理" />
</q-breadcrumbs>
</div>
<q-table title="Treats" :rows="rows" :columns="columns" row-key="id" :filter="filter" :loading="loading" :pagination="pagination">
<template v-slot:top>
<q-btn disabled color="primary" :disable="loading" label="新規" @click="addRow" />
<q-space />
<q-input borderless dense filled debounce="300" v-model="filter" placeholder="検索">
<template v-slot:append>
<q-icon name="search" />
</template>
</q-input>
</template>
<template v-slot:body-cell-actions="p">
<q-td :props="p">
<q-btn-group flat>
<q-btn flat color="primary" padding="xs" size="1em" icon="edit_note" @click="editFlow(p.row)" />
<q-btn disabled flat color="primary" padding="xs" size="1em" icon="history" @click="showHistory(p.row)" />
<q-btn disabled flat color="negative" padding="xs" size="1em" icon="delete_outline" @click="removeRow(p.row)" />
</q-btn-group>
</q-td>
</template>
</q-table>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, reactive } from 'vue';
import { api } from 'boot/axios';
import { useAuthStore } from 'stores/useAuthStore';
import { useFlowEditorStore } from 'stores/flowEditor';
import { router } from 'src/router';
const authStore = useAuthStore();
const columns = [
{ name: 'id', label: 'アプリID', field: 'id', align: 'left', sortable: true },
{ name: 'name', label: 'アプリ名', field: 'name', align: 'left', sortable: true },
{ name: 'url', label: 'URL', field: 'url', align: 'left', sortable: true },
{ name: 'user', label: 'ログイン名', field: 'user', align: 'left', },
{ name: 'version', label: 'バージョン', field: 'version', align: 'left', },
{ name: 'actions', label: '操作', field: 'actions' }
];
const pagination = ref({ sortBy: 'id', descending: true, rowsPerPage: 20 });
const loading = ref(false);
const filter = ref('');
const rows = ref([]);
const store = useFlowEditorStore();
const tenantid = ref(authStore.currentDomain.id);
const getApps = async () => {
loading.value = true;
const result = await api.get('api/apps');
rows.value = result.data.map((item) => {
return { id: item.appid, name: item.appname, url: item.domainurl, user: item.user.email, version: item.version }
});
loading.value = false;
}
onMounted(async () => {
await getApps();
})
const addRow = () => {
return
}
const removeRow = (row) => {
return
}
const showHistory = (row) => {
return
}
const editFlow = (row) => {
store.setApp({
appId: row.id,
name: row.name
});
router.push('/FlowChart');
};
</script>

View File

@@ -290,20 +290,8 @@ const onSaveAllFlow= async ()=>{
}
const fetchData = async () => {
await store.loadFlow();
drawerLeft.value = true;
if (store.appInfo === undefined) return;
const flowCtrl = new FlowCtrl();
const actionFlows = await flowCtrl.getFlows(store.appInfo?.appId);
if (actionFlows && actionFlows.length > 0) {
store.setFlows(actionFlows);
}
if (actionFlows && actionFlows.length == 1) {
store.selectFlow(actionFlows[0]);
}
const root = actionFlows[0].getRoot();
if (root) {
store.setActiveNode(root);
}
}
const onClearFilter=()=>{

View File

@@ -47,13 +47,6 @@ export default route(function (/* { store, ssrContext } */) {
authStore.returnUrl = to.fullPath;
return '/login';
}
// redirect to domain setting page if no domain exist
const domainPages = [...publicPages, '/domain'];
if (!authStore.hasDomain && !domainPages.includes(to.path)) {
authStore.returnUrl = to.fullPath;
return '/domain';
}
});
return routerInstance;
});

View File

@@ -27,6 +27,7 @@ const routes: RouteRecordRaw[] = [
{ path: 'domain', component: () => import('pages/TenantDomain.vue') },
{ path: 'userdomain', component: () => import('pages/UserDomain.vue')},
{ path: 'user', component: () => import('pages/UserManagement.vue')},
{ path: 'app', component: () => import('pages/AppManagement.vue')},
{ path: 'condition', component: () => import('pages/conditionPage.vue') }
],
},

View File

@@ -64,7 +64,9 @@ export const useFlowEditorStore = defineStore('flowEditor', {
this.selectedFlow = flow;
if(flow!==undefined){
const eventId = flow.getRoot()?.name;
this.selectedEvent=this.eventTree.findEventById(eventId) as IKintoneEvent;
this.selectedEvent = this.eventTree.findEventById(eventId) as IKintoneEvent;
} else {
this.selectedEvent = undefined;
}
},
setActiveNode(node: IActionNode) {
@@ -86,8 +88,8 @@ export const useFlowEditorStore = defineStore('flowEditor', {
//eventTreeにバンドする
this.eventTree.bindFlows(actionFlows);
if (actionFlows === undefined || actionFlows.length === 0) {
this.flows = [];
this.selectedFlow = undefined;
this.setFlows([]);
this.selectFlow(undefined);
this.expandedScreen =[];
return;
}
@@ -95,6 +97,11 @@ export const useFlowEditorStore = defineStore('flowEditor', {
if (actionFlows && actionFlows.length > 0) {
this.selectFlow(actionFlows[0]);
}
const root = actionFlows[0].getRoot();
if (root) {
this.setActiveNode(root);
}
const expandEventIds = actionFlows.map((flow) => flow.getRoot()?.name);
const expandScreens:string[]=[];
expandEventIds.forEach((eventid)=>{

View File

@@ -33,9 +33,6 @@ export const useAuthStore = defineStore('auth', {
toggleLeftDrawer(): boolean {
return this.LeftDrawer;
},
hasDomain(): boolean {
return this.currentDomain.id === null;
}
},
actions: {
toggleLeftMenu() {
@@ -63,11 +60,11 @@ export const useAuthStore = defineStore('auth', {
}
},
async getCurrentDomain(): Promise<IDomainInfo> {
const activedomain = (await api.get(`api/activedomain`))?.data;
const activedomain = await api.get(`api/activedomain`);
return {
id: activedomain?.id,
domainName: activedomain?.name,
kintoneUrl: activedomain?.url,
id: activedomain.data.id,
domainName: activedomain.data.name,
kintoneUrl: activedomain.data.url,
};
},
async getUserDomains(): Promise<IDomainInfo[]> {