diff --git a/packages/core/client/package.json b/packages/core/client/package.json index 5b75b8a3a..8139c79ab 100644 --- a/packages/core/client/package.json +++ b/packages/core/client/package.json @@ -42,6 +42,7 @@ "markdown-it-highlightjs": "3.3.1", "mathjs": "^10.6.0", "mermaid": "9.4.3", + "mitt": "^3.0.1", "nanoid": "^3.3.6", "rc-tree-select": "^5.15.0", "rc-util": "^5.32.0", diff --git a/packages/core/client/src/application/NoticesManager.tsx b/packages/core/client/src/application/NoticesManager.tsx index 35721a2e2..3bb4043d0 100644 --- a/packages/core/client/src/application/NoticesManager.tsx +++ b/packages/core/client/src/application/NoticesManager.tsx @@ -1,6 +1,7 @@ -import React, { createContext, ReactNode, useContext } from 'react'; +import React, { createContext, ReactNode, useContext, useEffect } from 'react'; import { message, notification } from 'antd'; +import mitt, { Emitter, EventType } from 'mitt'; import { Application } from './Application'; import { useApp } from './hooks'; @@ -17,6 +18,7 @@ export const enum NoticeType { STATUS = 'status', TOAST = 'toast', NOTIFICATION = 'notification', + CUSTOM = 'custom', } export interface NoticeManagerContextProps { @@ -36,15 +38,29 @@ export const useNoticeManager = () => { return useContext(NoticeManagerContext); }; +export const useNoticeSub = (name: string, handler: () => {}) => { + const { manager } = useNoticeManager(); + useEffect(() => { + manager.emitter.on(name, handler); + return () => { + manager.emitter.off(name, handler); + }; + }, [manager.emitter, name, handler]); +}; + export class NoticeManager { private ws: WebSocketClient; + public emitter: Emitter>; constructor(private app: Application) { this.ws = app.ws; + this.emitter = mitt(); } - on(data: { type: NoticeType; title?: string; content: string; level: NoticeLevel }) { + on(data: { type: NoticeType; title?: string; content: string; level: NoticeLevel; eventType?: string; event?: any }) { if (data.type === NoticeType.NOTIFICATION) { this[data.type](data.title, data.content, data.level); + } else if (data.type === NoticeType.CUSTOM) { + this.emitter.emit(data.eventType, data.event); } else { this[data.type](data.content, data.level); } diff --git a/packages/core/client/src/application/index.ts b/packages/core/client/src/application/index.ts index 55afe7657..6185ad91d 100644 --- a/packages/core/client/src/application/index.ts +++ b/packages/core/client/src/application/index.ts @@ -8,5 +8,6 @@ export * from './schema-initializer'; export * from './schema-settings'; export * from './schema-toolbar'; export * from './PluginSettingsManager'; +export * from './NoticesManager'; export * from './hoc'; export { ApplicationContext } from './context'; diff --git a/packages/core/client/src/built-in/admin-layout/index.tsx b/packages/core/client/src/built-in/admin-layout/index.tsx index 74cd4f394..aff3ff066 100644 --- a/packages/core/client/src/built-in/admin-layout/index.tsx +++ b/packages/core/client/src/built-in/admin-layout/index.tsx @@ -12,6 +12,7 @@ import { findByUid, findMenuItem, NavigateIfNotSignIn, + NoticeManagerProvider, PinnedPluginList, RemoteCollectionManagerProvider, RemoteSchemaTemplateManagerPlugin, @@ -440,15 +441,17 @@ export const InternalAdminLayout = (props: any) => { export const AdminProvider = (props) => { return ( - - - - - {props.children} - - - - + + + + + + {props.children} + + + + + ); }; diff --git a/packages/core/server/package.json b/packages/core/server/package.json index 963e5e3a2..4c3b7ade1 100644 --- a/packages/core/server/package.json +++ b/packages/core/server/package.json @@ -6,7 +6,7 @@ "types": "./lib/index.d.ts", "dependencies": { "@hapi/topo": "^6.0.0", - "@koa/cors": "^3.1.0", + "@koa/cors": "^5.0.0", "@koa/multer": "^3.0.2", "@koa/router": "^9.4.0", "@tachybase/acl": "workspace:*", @@ -55,6 +55,8 @@ }, "devDependencies": { "@types/koa": "^2.13.4", + "@types/koa-bodyparser": "^4.3.12", + "@types/koa__cors": "^5.0.0", "@types/lodash": "4.17.0", "@types/qs": "^6.9.14", "@types/semver": "^7.3.9", @@ -63,7 +65,7 @@ "async-mutex": "^0.3.2", "execa": "^5.1.1", "fast-glob": "^3.3.1", - "glob": "^7.1.6", + "glob": "^10.4.1", "koa-compose": "^4.1.0", "qs": "^6.11.2", "redis": "^4.6.10" diff --git a/packages/core/server/src/app-supervisor.ts b/packages/core/server/src/app-supervisor.ts index 2187b2473..2b834955c 100644 --- a/packages/core/server/src/app-supervisor.ts +++ b/packages/core/server/src/app-supervisor.ts @@ -1,6 +1,8 @@ -import { applyMixins, AsyncEmitter } from '@tachybase/utils'; -import { Mutex } from 'async-mutex'; import { EventEmitter } from 'events'; +import { applyMixins, AsyncEmitter } from '@tachybase/utils'; + +import { Mutex } from 'async-mutex'; + import Application, { ApplicationOptions, MaintainingCommandStatus } from './application'; import { getErrorLevel } from './errors/handler'; diff --git a/packages/core/server/src/application.ts b/packages/core/server/src/application.ts index dee935578..d78ac0d12 100644 --- a/packages/core/server/src/application.ts +++ b/packages/core/server/src/application.ts @@ -29,7 +29,6 @@ import lodash from 'lodash'; import _ from 'lodash'; import { nanoid } from 'nanoid'; import semver from 'semver'; -import winston from 'winston'; import packageJson from '../package.json'; import { createACL } from './acl'; @@ -50,9 +49,12 @@ import { import { ApplicationVersion } from './helpers/application-version'; import { Locale } from './locale'; import { MainDataSource } from './main-data-source'; +import { NoticeManager } from './notice'; import { Plugin } from './plugin'; import { InstallOptions, PluginManager } from './plugin-manager'; +export { Logger } from 'winston'; + export type PluginType = string | typeof Plugin; export type PluginConfiguration = PluginType | [PluginType, any]; @@ -183,6 +185,7 @@ export class Application exten private _maintainingCommandStatus: MaintainingCommandStatus; private _maintainingStatusBeforeCommand: MaintainingCommandStatus | null; private _actionCommand: Command; + private _noticeManager: NoticeManager; static KEY_CORE_APP_PREFIX = 'KEY_CORE_APP_'; private currentId = nanoid(); @@ -193,6 +196,11 @@ export class Application exten this.init(); this._appSupervisor.addApp(this); + this._noticeManager = new NoticeManager(this); + } + + get noticeManager() { + return this._noticeManager; } protected _loaded: boolean; diff --git a/packages/core/server/src/helper.ts b/packages/core/server/src/helper.ts index 299ab01c9..e01cf17db 100644 --- a/packages/core/server/src/helper.ts +++ b/packages/core/server/src/helper.ts @@ -1,14 +1,16 @@ -import cors from '@koa/cors'; +import { randomUUID } from 'crypto'; +import fs from 'fs'; +import { resolve } from 'path'; +import { createHistogram, RecordableHistogram } from 'perf_hooks'; import { requestLogger } from '@tachybase/logger'; import { Resourcer } from '@tachybase/resourcer'; import { uid } from '@tachybase/utils'; + +import cors from '@koa/cors'; import { Command } from 'commander'; -import { randomUUID } from 'crypto'; -import fs from 'fs'; import i18next from 'i18next'; import bodyParser from 'koa-bodyparser'; -import { resolve } from 'path'; -import { createHistogram, RecordableHistogram } from 'perf_hooks'; + import Application, { ApplicationOptions } from './application'; import { parseVariables } from './middlewares'; import { dateTemplate } from './middlewares/data-template'; diff --git a/packages/core/server/src/index.ts b/packages/core/server/src/index.ts index 1d37fef43..166bc48a2 100644 --- a/packages/core/server/src/index.ts +++ b/packages/core/server/src/index.ts @@ -6,3 +6,4 @@ export * from './plugin'; export * from './plugin-manager'; export * from './gateway'; export * from './app-supervisor'; +export * from './notice'; diff --git a/packages/core/server/src/notice/index.ts b/packages/core/server/src/notice/index.ts new file mode 100644 index 000000000..be48bc239 --- /dev/null +++ b/packages/core/server/src/notice/index.ts @@ -0,0 +1,59 @@ +import Application from '../application'; +import { Gateway } from '../gateway'; +import { WSServer } from '../gateway/ws-server'; + +export const enum NoticeLevel { + INFO = 'info', + WARNING = 'warning', + SUCCESS = 'success', + ERROR = 'error', +} + +export const enum NoticeType { + STATUS = 'status', + TOAST = 'toast', + NOTIFICATION = 'notification', + CUSTOM = 'custom', +} + +export class NoticeManager { + private ws: WSServer; + constructor(private app: Application) { + const gateway = Gateway.getInstance(); + this.ws = gateway['wsServer']; + } + + #emit(msg: { + type: NoticeType; + title?: string; + content?: string; + level?: NoticeLevel; + eventType?: string; + event?: unknown; + }) { + this.ws.sendToConnectionsByTag('app', this.app.name, { + type: 'notice', + payload: msg, + }); + } + + notify(eventType: string, event: unknown) { + this.#emit({ + type: NoticeType.CUSTOM, + eventType, + event, + }); + } + + status(content: string, level: NoticeLevel) { + this.#emit({ type: NoticeType.STATUS, content, level }); + } + + toast(content: string, level: NoticeLevel) { + this.#emit({ type: NoticeType.TOAST, content, level }); + } + + notification(title: string, content: string, level: NoticeLevel) { + this.#emit({ type: NoticeType.NOTIFICATION, title, content, level }); + } +} diff --git a/packages/core/server/src/plugin.ts b/packages/core/server/src/plugin.ts index ec71d80c4..72c456b2c 100644 --- a/packages/core/server/src/plugin.ts +++ b/packages/core/server/src/plugin.ts @@ -1,12 +1,14 @@ +import fs from 'fs'; +import { basename, resolve } from 'path'; import { Model } from '@tachybase/database'; import { LoggerOptions } from '@tachybase/logger'; import { fsExists, importModule } from '@tachybase/utils'; -import fs from 'fs'; + import glob from 'glob'; import type { TFuncKey, TOptions } from 'i18next'; -import { basename, resolve } from 'path'; + import { Application } from './application'; -import { InstallOptions, getExposeChangelogUrl, getExposeReadmeUrl } from './plugin-manager'; +import { getExposeChangelogUrl, getExposeReadmeUrl, InstallOptions } from './plugin-manager'; import { checkAndGetCompatible } from './plugin-manager/utils'; export interface PluginInterface { @@ -61,6 +63,10 @@ export abstract class Plugin implements PluginInterface { }); } + get noticeManager() { + return this.app.noticeManager; + } + get name() { return this.options.name as string; } diff --git a/packages/plugins/@hera/plugin-core/src/server/plugin.ts b/packages/plugins/@hera/plugin-core/src/server/plugin.ts index 5e0512c63..0f9e2aceb 100644 --- a/packages/plugins/@hera/plugin-core/src/server/plugin.ts +++ b/packages/plugins/@hera/plugin-core/src/server/plugin.ts @@ -1,7 +1,7 @@ import './actions'; import path from 'path'; -import Application, { InstallOptions, Plugin, type PluginOptions } from '@tachybase/server'; +import Application, { InstallOptions, NoticeLevel, Plugin, type PluginOptions } from '@tachybase/server'; import { Container } from '@tachybase/utils'; import { DepartmentsPlugin } from './features/departments'; diff --git a/packages/plugins/@tachybase/plugin-backup-restore/package.json b/packages/plugins/@tachybase/plugin-backup-restore/package.json index 29c543c79..d4f1a385e 100644 --- a/packages/plugins/@tachybase/plugin-backup-restore/package.json +++ b/packages/plugins/@tachybase/plugin-backup-restore/package.json @@ -14,6 +14,7 @@ "@koa/multer": "^3.0.2", "@tachybase/components": "workspace:*", "@types/archiver": "^5.3.1", + "@types/file-saver": "^2.0.7", "@types/lodash": "4.17.0", "antd": "5.16.1", "archiver": "^5.3.1", diff --git a/packages/plugins/@tachybase/plugin-backup-restore/src/client/Configuration.tsx b/packages/plugins/@tachybase/plugin-backup-restore/src/client/Configuration.tsx index 35edaa77b..7bf4159cb 100644 --- a/packages/plugins/@tachybase/plugin-backup-restore/src/client/Configuration.tsx +++ b/packages/plugins/@tachybase/plugin-backup-restore/src/client/Configuration.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { Checkbox, DatePicker, useAPIClient, useCompile } from '@tachybase/client'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Checkbox, DatePicker, useAPIClient, useCompile, useNoticeSub } from '@tachybase/client'; import { FormItem } from '@tachybase/components'; import { InboxOutlined, PlusOutlined, ReloadOutlined, UploadOutlined } from '@ant-design/icons'; @@ -142,7 +142,7 @@ const LearnMore: any = (props: { collectionsData?: any; isBackup?: boolean }) => ); }; -const Restore: React.FC = ({ ButtonComponent = Button, title, upload = false, fileData }) => { +const Restore = ({ ButtonComponent = Button, title, upload = false, fileData }: any) => { const { t } = useDuplicatorTranslation(); const [dataTypes, setDataTypes] = useState(['required']); const [isModalOpen, setIsModalOpen] = useState(false); @@ -232,7 +232,7 @@ const Restore: React.FC = ({ ButtonComponent = Button, title, upload = fals ); }; -const NewBackup: React.FC = ({ ButtonComponent = Button, refresh }) => { +const NewBackup = ({ ButtonComponent = Button, refresh }) => { const { t } = useDuplicatorTranslation(); const [isModalOpen, setIsModalOpen] = useState(false); const [dataTypes, setBackupData] = useState(['required']); @@ -295,7 +295,7 @@ const NewBackup: React.FC = ({ ButtonComponent = Button, refresh }) => { ); }; -const RestoreUpload: React.FC = (props: any) => { +const RestoreUpload = (props: any) => { const { t } = useDuplicatorTranslation(); const uploadProps: UploadProps = { multiple: false, @@ -338,6 +338,12 @@ export const BackupAndRestoreList = () => { return apiClient.resource('backupFiles'); }, [apiClient]); + const handleRefresh = useCallback(async () => { + await queryFieldList(); + }, []); + + useNoticeSub('backup', handleRefresh); + useEffect(() => { queryFieldList(); }, []); @@ -361,9 +367,6 @@ export const BackupAndRestoreList = () => { const blob = new Blob([data.data]); saveAs(blob, fileData.name); }; - const handleRefresh = async () => { - await queryFieldList(); - }; const handleDestory = (fileData) => { modal.confirm({ title: t('Delete record', { ns: 'client' }), @@ -393,7 +396,7 @@ export const BackupAndRestoreList = () => { /> - dataSource={dataSource} loading={loading} columns={[ diff --git a/packages/plugins/@tachybase/plugin-backup-restore/src/client/DuplicatorProvider.tsx b/packages/plugins/@tachybase/plugin-backup-restore/src/client/DuplicatorProvider.tsx index 141ce4133..699a2cd53 100644 --- a/packages/plugins/@tachybase/plugin-backup-restore/src/client/DuplicatorProvider.tsx +++ b/packages/plugins/@tachybase/plugin-backup-restore/src/client/DuplicatorProvider.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { CurrentAppInfoProvider, SchemaComponentOptions } from '@tachybase/client'; +import { SchemaComponentOptions } from '@tachybase/client'; export const DuplicatorProvider = function (props) { return {props.children}; diff --git a/packages/plugins/@tachybase/plugin-backup-restore/src/server/dumper.ts b/packages/plugins/@tachybase/plugin-backup-restore/src/server/dumper.ts index ce7518167..6af58862b 100644 --- a/packages/plugins/@tachybase/plugin-backup-restore/src/server/dumper.ts +++ b/packages/plugins/@tachybase/plugin-backup-restore/src/server/dumper.ts @@ -237,6 +237,7 @@ export class Dumper extends AppMigrator { }).finally(() => { this.cleanLockFile(backupFileName); Dumper.dumpTasks.delete(backupFileName); + this.app.noticeManager.notify('backup', { msg: 'done' }); }); Dumper.dumpTasks.set(backupFileName, promise); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4509cb9a..52fd3fee9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -536,6 +536,9 @@ importers: mermaid: specifier: 9.4.3 version: 9.4.3 + mitt: + specifier: ^3.0.1 + version: 3.0.1 nanoid: specifier: ^3.3.6 version: 3.3.7 @@ -1053,8 +1056,8 @@ importers: specifier: ^6.0.0 version: 6.0.2 '@koa/cors': - specifier: ^3.1.0 - version: 3.4.3 + specifier: ^5.0.0 + version: 5.0.0 '@koa/multer': specifier: ^3.0.2 version: 3.0.2(multer@1.4.4) @@ -1194,6 +1197,12 @@ importers: '@types/koa': specifier: ^2.13.4 version: 2.13.12 + '@types/koa-bodyparser': + specifier: ^4.3.12 + version: 4.3.12 + '@types/koa__cors': + specifier: ^5.0.0 + version: 5.0.0 '@types/lodash': specifier: 4.17.0 version: 4.17.0 @@ -1219,8 +1228,8 @@ importers: specifier: ^3.3.1 version: 3.3.2 glob: - specifier: ^7.1.6 - version: 7.2.3 + specifier: ^10.4.1 + version: 10.4.1 koa-compose: specifier: ^4.1.0 version: 4.1.0 @@ -2125,6 +2134,9 @@ importers: '@types/archiver': specifier: ^5.3.1 version: 5.3.4 + '@types/file-saver': + specifier: ^2.0.7 + version: 2.0.7 '@types/lodash': specifier: 4.17.0 version: 4.17.0 @@ -9735,9 +9747,9 @@ packages: resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} dev: false - /@koa/cors@3.4.3: - resolution: {integrity: sha512-WPXQUaAeAMVaLTEFpoq3T2O1C+FstkjJnDQqy95Ck1UdILajsRhu6mhJ8H2f4NFPRBoCNN+qywTJfq/gGki5mw==} - engines: {node: '>= 8.0.0'} + /@koa/cors@5.0.0: + resolution: {integrity: sha512-x/iUDjcS90W69PryLDIMgFyV21YLTnG9zOpPXS7Bkt2b8AsY3zZsIpOLBkYr9fBcF3HbkKaER5hOBZLfpLgYNw==} + engines: {node: '>= 14.0.0'} dependencies: vary: 1.1.2 dev: false @@ -12062,6 +12074,10 @@ packages: '@types/qs': 6.9.14 '@types/serve-static': 1.15.5 + /@types/file-saver@2.0.7: + resolution: {integrity: sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==} + dev: true + /@types/fs-extra@11.0.4: resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==} dependencies: @@ -12164,7 +12180,6 @@ packages: resolution: {integrity: sha512-hKMmRMVP889gPIdLZmmtou/BijaU1tHPyMNmcK7FAHAdATnRcGQQy78EqTTxLH1D4FTsrxIzklAQCso9oGoebQ==} dependencies: '@types/koa': 2.13.12 - dev: false /@types/koa-compose@3.2.8: resolution: {integrity: sha512-4Olc63RY+MKvxMwVknCUDhRQX1pFQoBZ/lXcRLP69PQkEpze/0cr8LNqJQe5NFb/b19DWi2a5bTi2VAlQzhJuA==} @@ -12195,6 +12210,12 @@ packages: '@types/koa-compose': 3.2.8 '@types/node': 20.12.8 + /@types/koa__cors@5.0.0: + resolution: {integrity: sha512-LCk/n25Obq5qlernGOK/2LUwa/2YJb2lxHUkkvYFDOpLXlVI6tKcdfCHRBQnOY4LwH6el5WOLs6PD/a8Uzau6g==} + dependencies: + '@types/koa': 2.13.12 + dev: true + /@types/linkify-it@3.0.5: resolution: {integrity: sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==} dev: true @@ -19276,8 +19297,21 @@ packages: path-scurry: 1.10.2 dev: true + /glob@10.4.1: + resolution: {integrity: sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==} + engines: {node: '>=16 || 14 >=14.18'} + hasBin: true + dependencies: + foreground-child: 3.1.1 + jackspeak: 3.3.0 + minimatch: 9.0.4 + minipass: 7.1.2 + path-scurry: 1.11.1 + dev: true + /glob@7.1.6: resolution: {integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==} + deprecated: Glob versions prior to v9 are no longer supported dependencies: fs.realpath: 1.0.0 inflight: 1.0.6 @@ -20560,6 +20594,15 @@ packages: '@pkgjs/parseargs': 0.11.0 dev: true + /jackspeak@3.3.0: + resolution: {integrity: sha512-glPiBfKguqA7v8JsXO3iLjJWZ9FV1vNpoI0I9hI9Mnk5yetO9uPLSpiCEmiVijAssv2f54HpvtzvAHfhPieiDQ==} + engines: {node: '>=14'} + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + dev: true + /javascript-natural-sort@0.7.1: resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==} @@ -22049,6 +22092,11 @@ packages: engines: {node: '>=16 || 14 >=14.17'} dev: true + /minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + dev: true + /minizlib@2.1.2: resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} engines: {node: '>= 8'} @@ -23099,6 +23147,14 @@ packages: minipass: 7.0.4 dev: true + /path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + dependencies: + lru-cache: 10.2.0 + minipass: 7.1.2 + dev: true + /path-temp@2.1.0: resolution: {integrity: sha512-cMMJTAZlion/RWRRC48UbrDymEIt+/YSD/l8NqjneyDw2rDOBQcP5yRkMB4CYGn47KMhZvbblBP7Z79OsMw72w==} engines: {node: '>=8.15'}