Signed-off-by: -LAN- <laipz8200@outlook.com> Co-authored-by: -LAN- <laipz8200@outlook.com>tags/1.0.1
| from datetime import datetime | |||||
| from flask_restful import Resource, marshal_with, reqparse # type: ignore | from flask_restful import Resource, marshal_with, reqparse # type: ignore | ||||
| from flask_restful.inputs import int_range # type: ignore | from flask_restful.inputs import int_range # type: ignore | ||||
| from sqlalchemy.orm import Session | |||||
| from controllers.console import api | from controllers.console import api | ||||
| from controllers.console.app.wraps import get_app_model | from controllers.console.app.wraps import get_app_model | ||||
| from controllers.console.wraps import account_initialization_required, setup_required | from controllers.console.wraps import account_initialization_required, setup_required | ||||
| from extensions.ext_database import db | |||||
| from fields.workflow_app_log_fields import workflow_app_log_pagination_fields | from fields.workflow_app_log_fields import workflow_app_log_pagination_fields | ||||
| from libs.login import login_required | from libs.login import login_required | ||||
| from models import App | from models import App | ||||
| from models.model import AppMode | from models.model import AppMode | ||||
| from models.workflow import WorkflowRunStatus | |||||
| from services.workflow_app_service import WorkflowAppService | from services.workflow_app_service import WorkflowAppService | ||||
| parser = reqparse.RequestParser() | parser = reqparse.RequestParser() | ||||
| parser.add_argument("keyword", type=str, location="args") | parser.add_argument("keyword", type=str, location="args") | ||||
| parser.add_argument("status", type=str, choices=["succeeded", "failed", "stopped"], location="args") | parser.add_argument("status", type=str, choices=["succeeded", "failed", "stopped"], location="args") | ||||
| parser.add_argument( | |||||
| "created_at__before", type=str, location="args", help="Filter logs created before this timestamp" | |||||
| ) | |||||
| parser.add_argument( | |||||
| "created_at__after", type=str, location="args", help="Filter logs created after this timestamp" | |||||
| ) | |||||
| parser.add_argument("page", type=int_range(1, 99999), default=1, location="args") | parser.add_argument("page", type=int_range(1, 99999), default=1, location="args") | ||||
| parser.add_argument("limit", type=int_range(1, 100), default=20, location="args") | parser.add_argument("limit", type=int_range(1, 100), default=20, location="args") | ||||
| args = parser.parse_args() | args = parser.parse_args() | ||||
| args.status = WorkflowRunStatus(args.status) if args.status else None | |||||
| if args.created_at__before: | |||||
| args.created_at__before = datetime.fromisoformat(args.created_at__before.replace("Z", "+00:00")) | |||||
| if args.created_at__after: | |||||
| args.created_at__after = datetime.fromisoformat(args.created_at__after.replace("Z", "+00:00")) | |||||
| # get paginate workflow app logs | # get paginate workflow app logs | ||||
| workflow_app_service = WorkflowAppService() | workflow_app_service = WorkflowAppService() | ||||
| workflow_app_log_pagination = workflow_app_service.get_paginate_workflow_app_logs( | |||||
| app_model=app_model, args=args | |||||
| ) | |||||
| with Session(db.engine) as session: | |||||
| workflow_app_log_pagination = workflow_app_service.get_paginate_workflow_app_logs( | |||||
| session=session, | |||||
| app_model=app_model, | |||||
| keyword=args.keyword, | |||||
| status=args.status, | |||||
| created_at_before=args.created_at__before, | |||||
| created_at_after=args.created_at__after, | |||||
| page=args.page, | |||||
| limit=args.limit, | |||||
| ) | |||||
| return workflow_app_log_pagination | |||||
| return workflow_app_log_pagination | |||||
| api.add_resource(WorkflowAppLogApi, "/apps/<uuid:app_id>/workflow-app-logs") | api.add_resource(WorkflowAppLogApi, "/apps/<uuid:app_id>/workflow-app-logs") |
| import logging | import logging | ||||
| from datetime import datetime | |||||
| from flask_restful import Resource, fields, marshal_with, reqparse # type: ignore | from flask_restful import Resource, fields, marshal_with, reqparse # type: ignore | ||||
| from flask_restful.inputs import int_range # type: ignore | from flask_restful.inputs import int_range # type: ignore | ||||
| from sqlalchemy.orm import Session | |||||
| from werkzeug.exceptions import InternalServerError | from werkzeug.exceptions import InternalServerError | ||||
| from controllers.service_api import api | from controllers.service_api import api | ||||
| from fields.workflow_app_log_fields import workflow_app_log_pagination_fields | from fields.workflow_app_log_fields import workflow_app_log_pagination_fields | ||||
| from libs import helper | from libs import helper | ||||
| from models.model import App, AppMode, EndUser | from models.model import App, AppMode, EndUser | ||||
| from models.workflow import WorkflowRun | |||||
| from models.workflow import WorkflowRun, WorkflowRunStatus | |||||
| from services.app_generate_service import AppGenerateService | from services.app_generate_service import AppGenerateService | ||||
| from services.workflow_app_service import WorkflowAppService | from services.workflow_app_service import WorkflowAppService | ||||
| parser = reqparse.RequestParser() | parser = reqparse.RequestParser() | ||||
| parser.add_argument("keyword", type=str, location="args") | parser.add_argument("keyword", type=str, location="args") | ||||
| parser.add_argument("status", type=str, choices=["succeeded", "failed", "stopped"], location="args") | parser.add_argument("status", type=str, choices=["succeeded", "failed", "stopped"], location="args") | ||||
| parser.add_argument("created_at__before", type=str, location="args") | |||||
| parser.add_argument("created_at__after", type=str, location="args") | |||||
| parser.add_argument("page", type=int_range(1, 99999), default=1, location="args") | parser.add_argument("page", type=int_range(1, 99999), default=1, location="args") | ||||
| parser.add_argument("limit", type=int_range(1, 100), default=20, location="args") | parser.add_argument("limit", type=int_range(1, 100), default=20, location="args") | ||||
| args = parser.parse_args() | args = parser.parse_args() | ||||
| args.status = WorkflowRunStatus(args.status) if args.status else None | |||||
| if args.created_at__before: | |||||
| args.created_at__before = datetime.fromisoformat(args.created_at__before.replace("Z", "+00:00")) | |||||
| if args.created_at__after: | |||||
| args.created_at__after = datetime.fromisoformat(args.created_at__after.replace("Z", "+00:00")) | |||||
| # get paginate workflow app logs | # get paginate workflow app logs | ||||
| workflow_app_service = WorkflowAppService() | workflow_app_service = WorkflowAppService() | ||||
| workflow_app_log_pagination = workflow_app_service.get_paginate_workflow_app_logs( | |||||
| app_model=app_model, args=args | |||||
| ) | |||||
| with Session(db.engine) as session: | |||||
| workflow_app_log_pagination = workflow_app_service.get_paginate_workflow_app_logs( | |||||
| session=session, | |||||
| app_model=app_model, | |||||
| keyword=args.keyword, | |||||
| status=args.status, | |||||
| created_at_before=args.created_at__before, | |||||
| created_at_after=args.created_at__after, | |||||
| page=args.page, | |||||
| limit=args.limit, | |||||
| ) | |||||
| return workflow_app_log_pagination | |||||
| return workflow_app_log_pagination | |||||
| api.add_resource(WorkflowRunApi, "/workflows/run") | api.add_resource(WorkflowRunApi, "/workflows/run") |
| workflow_app_log_pagination_fields = { | workflow_app_log_pagination_fields = { | ||||
| "page": fields.Integer, | "page": fields.Integer, | ||||
| "limit": fields.Integer(attribute="per_page"), | |||||
| "limit": fields.Integer, | |||||
| "total": fields.Integer, | "total": fields.Integer, | ||||
| "has_more": fields.Boolean(attribute="has_next"), | |||||
| "data": fields.List(fields.Nested(workflow_app_log_partial_fields), attribute="items"), | |||||
| "has_more": fields.Boolean, | |||||
| "data": fields.List(fields.Nested(workflow_app_log_partial_fields)), | |||||
| } | } |
| STOPPED = "stopped" | STOPPED = "stopped" | ||||
| PARTIAL_SUCCESSED = "partial-succeeded" | PARTIAL_SUCCESSED = "partial-succeeded" | ||||
| @classmethod | |||||
| def value_of(cls, value: str) -> "WorkflowRunStatus": | |||||
| """ | |||||
| Get value of given mode. | |||||
| :param value: mode value | |||||
| :return: mode | |||||
| """ | |||||
| for mode in cls: | |||||
| if mode.value == value: | |||||
| return mode | |||||
| raise ValueError(f"invalid workflow run status value {value}") | |||||
| class WorkflowRun(Base): | class WorkflowRun(Base): | ||||
| """ | """ |
| import uuid | import uuid | ||||
| from datetime import datetime | |||||
| from flask_sqlalchemy.pagination import Pagination | |||||
| from sqlalchemy import and_, or_ | |||||
| from sqlalchemy import and_, func, or_, select | |||||
| from sqlalchemy.orm import Session | |||||
| from extensions.ext_database import db | |||||
| from models import App, EndUser, WorkflowAppLog, WorkflowRun | from models import App, EndUser, WorkflowAppLog, WorkflowRun | ||||
| from models.enums import CreatedByRole | from models.enums import CreatedByRole | ||||
| from models.workflow import WorkflowRunStatus | from models.workflow import WorkflowRunStatus | ||||
| class WorkflowAppService: | class WorkflowAppService: | ||||
| def get_paginate_workflow_app_logs(self, app_model: App, args: dict) -> Pagination: | |||||
| def get_paginate_workflow_app_logs( | |||||
| self, | |||||
| *, | |||||
| session: Session, | |||||
| app_model: App, | |||||
| keyword: str | None = None, | |||||
| status: WorkflowRunStatus | None = None, | |||||
| created_at_before: datetime | None = None, | |||||
| created_at_after: datetime | None = None, | |||||
| page: int = 1, | |||||
| limit: int = 20, | |||||
| ) -> dict: | |||||
| """ | """ | ||||
| Get paginate workflow app logs | |||||
| :param app: app model | |||||
| :param args: request args | |||||
| :return: | |||||
| Get paginate workflow app logs using SQLAlchemy 2.0 style | |||||
| :param session: SQLAlchemy session | |||||
| :param app_model: app model | |||||
| :param keyword: search keyword | |||||
| :param status: filter by status | |||||
| :param created_at_before: filter logs created before this timestamp | |||||
| :param created_at_after: filter logs created after this timestamp | |||||
| :param page: page number | |||||
| :param limit: items per page | |||||
| :return: Pagination object | |||||
| """ | """ | ||||
| query = db.select(WorkflowAppLog).where( | |||||
| # Build base statement using SQLAlchemy 2.0 style | |||||
| stmt = select(WorkflowAppLog).where( | |||||
| WorkflowAppLog.tenant_id == app_model.tenant_id, WorkflowAppLog.app_id == app_model.id | WorkflowAppLog.tenant_id == app_model.tenant_id, WorkflowAppLog.app_id == app_model.id | ||||
| ) | ) | ||||
| status = WorkflowRunStatus.value_of(args.get("status", "")) if args.get("status") else None | |||||
| keyword = args["keyword"] | |||||
| if keyword or status: | if keyword or status: | ||||
| query = query.join(WorkflowRun, WorkflowRun.id == WorkflowAppLog.workflow_run_id) | |||||
| stmt = stmt.join(WorkflowRun, WorkflowRun.id == WorkflowAppLog.workflow_run_id) | |||||
| if keyword: | if keyword: | ||||
| keyword_like_val = f"%{keyword[:30].encode('unicode_escape').decode('utf-8')}%".replace(r"\u", r"\\u") | keyword_like_val = f"%{keyword[:30].encode('unicode_escape').decode('utf-8')}%".replace(r"\u", r"\\u") | ||||
| if keyword_uuid: | if keyword_uuid: | ||||
| keyword_conditions.append(WorkflowRun.id == keyword_uuid) | keyword_conditions.append(WorkflowRun.id == keyword_uuid) | ||||
| query = query.outerjoin( | |||||
| stmt = stmt.outerjoin( | |||||
| EndUser, | EndUser, | ||||
| and_(WorkflowRun.created_by == EndUser.id, WorkflowRun.created_by_role == CreatedByRole.END_USER), | and_(WorkflowRun.created_by == EndUser.id, WorkflowRun.created_by_role == CreatedByRole.END_USER), | ||||
| ).filter(or_(*keyword_conditions)) | |||||
| ).where(or_(*keyword_conditions)) | |||||
| if status: | if status: | ||||
| # join with workflow_run and filter by status | |||||
| query = query.filter(WorkflowRun.status == status.value) | |||||
| stmt = stmt.where(WorkflowRun.status == status) | |||||
| query = query.order_by(WorkflowAppLog.created_at.desc()) | |||||
| # Add time-based filtering | |||||
| if created_at_before: | |||||
| stmt = stmt.where(WorkflowAppLog.created_at <= created_at_before) | |||||
| pagination = db.paginate(query, page=args["page"], per_page=args["limit"], error_out=False) | |||||
| if created_at_after: | |||||
| stmt = stmt.where(WorkflowAppLog.created_at >= created_at_after) | |||||
| return pagination | |||||
| stmt = stmt.order_by(WorkflowAppLog.created_at.desc()) | |||||
| # Get total count using the same filters | |||||
| count_stmt = select(func.count()).select_from(stmt.subquery()) | |||||
| total = session.scalar(count_stmt) or 0 | |||||
| # Apply pagination limits | |||||
| offset_stmt = stmt.offset((page - 1) * limit).limit(limit) | |||||
| # Execute query and get items | |||||
| items = list(session.scalars(offset_stmt).all()) | |||||
| return { | |||||
| "page": page, | |||||
| "limit": limit, | |||||
| "total": total, | |||||
| "has_more": total > page * limit, | |||||
| "data": items, | |||||
| } | |||||
| @staticmethod | @staticmethod | ||||
| def _safe_parse_uuid(value: str): | def _safe_parse_uuid(value: str): |
| import type { FC } from 'react' | import type { FC } from 'react' | ||||
| import React from 'react' | import React from 'react' | ||||
| import { useTranslation } from 'react-i18next' | import { useTranslation } from 'react-i18next' | ||||
| import dayjs from 'dayjs' | |||||
| import { RiCalendarLine } from '@remixicon/react' | |||||
| import quarterOfYear from 'dayjs/plugin/quarterOfYear' | |||||
| import type { QueryParam } from './index' | import type { QueryParam } from './index' | ||||
| import Chip from '@/app/components/base/chip' | import Chip from '@/app/components/base/chip' | ||||
| import Input from '@/app/components/base/input' | import Input from '@/app/components/base/input' | ||||
| dayjs.extend(quarterOfYear) | |||||
| interface IFilterProps { | |||||
| const today = dayjs() | |||||
| export const TIME_PERIOD_MAPPING: { [key: string]: { value: number; name: string } } = { | |||||
| 1: { value: 0, name: 'today' }, | |||||
| 2: { value: 7, name: 'last7days' }, | |||||
| 3: { value: 28, name: 'last4weeks' }, | |||||
| 4: { value: today.diff(today.subtract(3, 'month'), 'day'), name: 'last3months' }, | |||||
| 5: { value: today.diff(today.subtract(12, 'month'), 'day'), name: 'last12months' }, | |||||
| 6: { value: today.diff(today.startOf('month'), 'day'), name: 'monthToDate' }, | |||||
| 7: { value: today.diff(today.startOf('quarter'), 'day'), name: 'quarterToDate' }, | |||||
| 8: { value: today.diff(today.startOf('year'), 'day'), name: 'yearToDate' }, | |||||
| 9: { value: -1, name: 'allTime' }, | |||||
| } | |||||
| type IFilterProps = { | |||||
| queryParams: QueryParam | queryParams: QueryParam | ||||
| setQueryParams: (v: QueryParam) => void | setQueryParams: (v: QueryParam) => void | ||||
| } | } | ||||
| { value: 'stopped', name: 'Stop' }, | { value: 'stopped', name: 'Stop' }, | ||||
| ]} | ]} | ||||
| /> | /> | ||||
| <Chip | |||||
| className='min-w-[150px]' | |||||
| panelClassName='w-[270px]' | |||||
| leftIcon={<RiCalendarLine className='h-4 w-4 text-text-secondary' />} | |||||
| value={queryParams.period} | |||||
| onSelect={(item) => { | |||||
| setQueryParams({ ...queryParams, period: item.value }) | |||||
| }} | |||||
| onClear={() => setQueryParams({ ...queryParams, period: '9' })} | |||||
| items={Object.entries(TIME_PERIOD_MAPPING).map(([k, v]) => ({ value: k, name: t(`appLog.filter.period.${v.name}`) }))} | |||||
| /> | |||||
| <Input | <Input | ||||
| wrapperClassName='w-[200px]' | wrapperClassName='w-[200px]' | ||||
| showLeftIcon | showLeftIcon |
| import useSWR from 'swr' | import useSWR from 'swr' | ||||
| import { usePathname } from 'next/navigation' | import { usePathname } from 'next/navigation' | ||||
| import { useDebounce } from 'ahooks' | import { useDebounce } from 'ahooks' | ||||
| import { omit } from 'lodash-es' | |||||
| import dayjs from 'dayjs' | |||||
| import utc from 'dayjs/plugin/utc' | |||||
| import timezone from 'dayjs/plugin/timezone' | |||||
| import { Trans, useTranslation } from 'react-i18next' | import { Trans, useTranslation } from 'react-i18next' | ||||
| import Link from 'next/link' | import Link from 'next/link' | ||||
| import List from './list' | import List from './list' | ||||
| import Filter from './filter' | |||||
| import Filter, { TIME_PERIOD_MAPPING } from './filter' | |||||
| import Pagination from '@/app/components/base/pagination' | import Pagination from '@/app/components/base/pagination' | ||||
| import Loading from '@/app/components/base/loading' | import Loading from '@/app/components/base/loading' | ||||
| import { fetchWorkflowLogs } from '@/service/log' | import { fetchWorkflowLogs } from '@/service/log' | ||||
| import { APP_PAGE_LIMIT } from '@/config' | import { APP_PAGE_LIMIT } from '@/config' | ||||
| import type { App, AppMode } from '@/types/app' | import type { App, AppMode } from '@/types/app' | ||||
| import { useAppContext } from '@/context/app-context' | |||||
| dayjs.extend(utc) | |||||
| dayjs.extend(timezone) | |||||
| export type ILogsProps = { | export type ILogsProps = { | ||||
| appDetail: App | appDetail: App | ||||
| } | } | ||||
| export type QueryParam = { | export type QueryParam = { | ||||
| period: string | |||||
| status?: string | status?: string | ||||
| keyword?: string | keyword?: string | ||||
| } | } | ||||
| const Logs: FC<ILogsProps> = ({ appDetail }) => { | const Logs: FC<ILogsProps> = ({ appDetail }) => { | ||||
| const { t } = useTranslation() | const { t } = useTranslation() | ||||
| const [queryParams, setQueryParams] = useState<QueryParam>({ status: 'all' }) | |||||
| const { userProfile: { timezone } } = useAppContext() | |||||
| const [queryParams, setQueryParams] = useState<QueryParam>({ status: 'all', period: '2' }) | |||||
| const [currPage, setCurrPage] = React.useState<number>(0) | const [currPage, setCurrPage] = React.useState<number>(0) | ||||
| const debouncedQueryParams = useDebounce(queryParams, { wait: 500 }) | const debouncedQueryParams = useDebounce(queryParams, { wait: 500 }) | ||||
| const [limit, setLimit] = React.useState<number>(APP_PAGE_LIMIT) | const [limit, setLimit] = React.useState<number>(APP_PAGE_LIMIT) | ||||
| limit, | limit, | ||||
| ...(debouncedQueryParams.status !== 'all' ? { status: debouncedQueryParams.status } : {}), | ...(debouncedQueryParams.status !== 'all' ? { status: debouncedQueryParams.status } : {}), | ||||
| ...(debouncedQueryParams.keyword ? { keyword: debouncedQueryParams.keyword } : {}), | ...(debouncedQueryParams.keyword ? { keyword: debouncedQueryParams.keyword } : {}), | ||||
| ...((debouncedQueryParams.period !== '9') | |||||
| ? { | |||||
| created_at__after: dayjs().subtract(TIME_PERIOD_MAPPING[debouncedQueryParams.period].value, 'day').startOf('day').tz(timezone).format('YYYY-MM-DDTHH:mm:ssZ'), | |||||
| created_at__before: dayjs().endOf('day').tz(timezone).format('YYYY-MM-DDTHH:mm:ssZ'), | |||||
| } | |||||
| : {}), | |||||
| ...omit(debouncedQueryParams, ['period', 'status']), | |||||
| } | } | ||||
| const getWebAppType = (appType: AppMode) => { | const getWebAppType = (appType: AppMode) => { |
| <div className='py-1'> | <div className='py-1'> | ||||
| <div className='flex leading-[18px] items-center'> | <div className='flex leading-[18px] items-center'> | ||||
| <div className='code-sm-semibold text-text-secondary'>{name}</div> | <div className='code-sm-semibold text-text-secondary'>{name}</div> | ||||
| <div className='ml-2 system-xs-regular text-text-tertiary'>{type}</div> | |||||
| <div className='ml-2 system-xs-regular text-text-tertiary capitalize'>{type}</div> | |||||
| </div> | </div> | ||||
| <div className='mt-0.5 system-xs-regular text-text-tertiary'> | <div className='mt-0.5 system-xs-regular text-text-tertiary'> | ||||
| {description} | {description} |
| LogMessageAnnotationsResponse, | LogMessageAnnotationsResponse, | ||||
| LogMessageFeedbacksRequest, | LogMessageFeedbacksRequest, | ||||
| LogMessageFeedbacksResponse, | LogMessageFeedbacksResponse, | ||||
| WorkflowLogsRequest, | |||||
| WorkflowLogsResponse, | WorkflowLogsResponse, | ||||
| WorkflowRunDetailResponse, | WorkflowRunDetailResponse, | ||||
| } from '@/models/log' | } from '@/models/log' | ||||
| return get<AnnotationsCountResponse>(url) | return get<AnnotationsCountResponse>(url) | ||||
| } | } | ||||
| export const fetchWorkflowLogs: Fetcher<WorkflowLogsResponse, { url: string; params?: WorkflowLogsRequest }> = ({ url, params }) => { | |||||
| export const fetchWorkflowLogs: Fetcher<WorkflowLogsResponse, { url: string; params: Record<string, any> }> = ({ url, params }) => { | |||||
| return get<WorkflowLogsResponse>(url, { params }) | return get<WorkflowLogsResponse>(url, { params }) | ||||
| } | } | ||||