Compare commits
37 Commits
af40f0ba61
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 094336ebf1 | |||
| a310b671e2 | |||
| d9373b00f0 | |||
| 09bc3a65f5 | |||
| 1d81681e70 | |||
| d4bafa706c | |||
| 60f9f89d90 | |||
| 8ecfaf4945 | |||
| fa736a36ab | |||
| b7869ad6e6 | |||
| 982187298c | |||
| 8118dba74d | |||
| 97489d0361 | |||
| 924c00968c | |||
| 53c3b0ee7e | |||
| b2787882bf | |||
| aafa7ac351 | |||
| b7477e10a1 | |||
| 11f1dae1f3 | |||
| 59c379b22c | |||
| d275aa12c4 | |||
| c732f1e1b2 | |||
| 9e32502ee3 | |||
| 2d0ff2bf84 | |||
| 21940a3e18 | |||
| 2e517a1e08 | |||
| 10c5e51162 | |||
| 69e5126de6 | |||
| d044ebd157 | |||
| d6b3a44d61 | |||
| e57f2fe72f | |||
| dfb37c18b9 | |||
| 5f5b23026f | |||
| 5c61fc3f24 | |||
| a7aa5bc98c | |||
| bf63701e68 | |||
| f26aae296a |
@@ -0,0 +1,28 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="eapim-portal [clean compileJava]" type="GradleRunConfiguration" factoryName="Gradle" nameIsGenerated="true">
|
||||
<ExternalSystemSettings>
|
||||
<option name="executionName" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="externalSystemIdString" value="GRADLE" />
|
||||
<option name="scriptParameters" value="--no-build-cache -x test" />
|
||||
<option name="taskDescriptions">
|
||||
<list />
|
||||
</option>
|
||||
<option name="taskNames">
|
||||
<list>
|
||||
<option value="clean" />
|
||||
<option value="compileJava" />
|
||||
</list>
|
||||
</option>
|
||||
<option name="vmOptions" />
|
||||
</ExternalSystemSettings>
|
||||
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
|
||||
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
|
||||
<ExternalSystemDebugDisabled>false</ExternalSystemDebugDisabled>
|
||||
<DebugAllEnabled>false</DebugAllEnabled>
|
||||
<RunAsTest>false</RunAsTest>
|
||||
<GradleProfilingDisabled>false</GradleProfilingDisabled>
|
||||
<GradleCoverageDisabled>false</GradleCoverageDisabled>
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
+2
-1
@@ -74,8 +74,9 @@ pipeline {
|
||||
set -eu
|
||||
cd build/libs
|
||||
sha256sum eapim-portal.war > eapim-portal.war.sha256
|
||||
sha256sum eapim-portal-static.zip > eapim-portal-static.zip.sha256
|
||||
'''
|
||||
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal.war.sha256', fingerprint: true
|
||||
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal.war.sha256,build/libs/eapim-portal-static.zip,build/libs/eapim-portal-static.zip.sha256', fingerprint: true
|
||||
stash name: 'war', includes: 'build/libs/eapim-portal.war'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,8 +96,9 @@ pipeline {
|
||||
sha256sum "$f" > "$f.sha256"
|
||||
md5sum "$f" > "$f.md5"
|
||||
done
|
||||
sha256sum eapim-portal-static.zip > eapim-portal-static.zip.sha256
|
||||
'''
|
||||
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal-boot.war,build/libs/eapim-portal.war.sha1,build/libs/eapim-portal.war.sha256,build/libs/eapim-portal.war.md5,build/libs/eapim-portal-boot.war.sha1,build/libs/eapim-portal-boot.war.sha256,build/libs/eapim-portal-boot.war.md5', fingerprint: true
|
||||
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal-boot.war,build/libs/eapim-portal.war.sha1,build/libs/eapim-portal.war.sha256,build/libs/eapim-portal.war.md5,build/libs/eapim-portal-boot.war.sha1,build/libs/eapim-portal-boot.war.sha256,build/libs/eapim-portal-boot.war.md5,build/libs/eapim-portal-static.zip,build/libs/eapim-portal-static.zip.sha256', fingerprint: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+91
-11
@@ -83,7 +83,14 @@ dependencies {
|
||||
implementation group: 'com.fasterxml.woodstox', name: 'woodstox-core', version: '6.5.1'
|
||||
|
||||
|
||||
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
|
||||
// Thymeleaf 3.1 코어가 #temporals 를 내장 제공 → java8time extras 를 함께 두면 표현식 객체가 중복 등록된다.
|
||||
implementation('org.springframework.boot:spring-boot-starter-thymeleaf') {
|
||||
exclude group: 'org.thymeleaf.extras', module: 'thymeleaf-extras-java8time'
|
||||
}
|
||||
// WW-5417 관련 public 필드 접근 권한 검사 누락 수정(OGNL #264/#265).
|
||||
// 3.3.x EOL 계열의 단기 조치. 3.4.x는 Thymeleaf 3.1.5의 OgnlContext 생성자와 비호환.
|
||||
// 일반 OGNL 경로도 ThymeleafExpressionCompatibilityTest로 검증한다(Spring EL만으로는 확인 불가).
|
||||
implementation 'ognl:ognl:3.3.5'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||
implementation('org.springframework.boot:spring-boot-starter-cache')
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||
@@ -91,33 +98,48 @@ dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
|
||||
|
||||
developmentOnly 'org.springframework.boot:spring-boot-devtools'
|
||||
implementation 'org.springframework:spring-expression:5.3.30'
|
||||
// spring-expression 개별 pin 제거: 아래 ext 의 spring-framework.version 이 전 모듈을 일괄 관리한다.
|
||||
// 개별 pin 이 남아 있으면 다른 spring-* 모듈보다 낮은 버전으로 고정되어 버전이 어긋난다.
|
||||
|
||||
implementation group: 'xalan', name: 'xalan', version: '2.7.3'
|
||||
|
||||
implementation 'org.hibernate:hibernate-envers:5.6.15.Final'
|
||||
implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect:3.0.0'
|
||||
implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect:3.4.0' // 3.0.0 은 thymeleaf 3.0 전용
|
||||
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'
|
||||
|
||||
implementation 'com.github.ua-parser:uap-java:1.5.3'
|
||||
// uap-java 제거: 소스 전체에 ua_parser 참조 0건이고, 1.5.3 은 snakeyaml 2.x 에서 삭제된
|
||||
// SafeConstructor() no-arg 를 호출해 NoSuchMethodError 를 낸다. 되살릴 경우 1.6.1 이상.
|
||||
implementation 'org.apache.httpcomponents:httpclient:4.5.14'
|
||||
implementation 'com.navercorp.lucy:lucy-xss-servlet:2.0.1'
|
||||
implementation 'javax.servlet:javax.servlet-api:4.0.0'
|
||||
implementation 'org.jasypt:jasypt:1.9.3'
|
||||
implementation 'xerces:xercesImpl:2.12.2'
|
||||
|
||||
implementation 'org.apache.commons:commons-lang3:3.12.0'
|
||||
// weblogic.xml prefer-application-packages 가 com.fasterxml.jackson.* 를 앱 우선으로 강제한다
|
||||
// (oracle_common jackson-databind 2.17.0 vs 앱 2.18.10 split-package 방지, WLog 실사용 확인).
|
||||
// Thymeleaf StandardJavaScriptSerializer 가 ObjectMapper.findAndRegisterModules() 로
|
||||
// jackson-module-jaxb-annotations 를 옵션 모듈로 자동탐색하는데, 이 모듈은 WebLogic 번들에만
|
||||
// 있고 앱엔 없어 ServiceConfigurationError(Provider not found) 발생 → 앱에도 명시 추가해
|
||||
// jackson 전체를 자체 완결시킨다(jackson-bom 2.18.10 로 버전 통일).
|
||||
implementation 'com.fasterxml.jackson.module:jackson-module-jaxb-annotations'
|
||||
|
||||
// Uncontrolled recursion in ClassUtils.getClass(...) on very long inputs. 3.18.0+ 에서 수정.
|
||||
implementation 'org.apache.commons:commons-lang3:3.20.0'
|
||||
implementation 'org.apache.commons:commons-collections4:4.4'
|
||||
|
||||
implementation 'commons-net:commons-net:3.9.0'
|
||||
implementation('commons-beanutils:commons-beanutils:1.9.4') {
|
||||
// CVE-2025-48734 (PropertyUtilsBean 이 enum 의 declaredClass 프로퍼티 노출 → ClassLoader 접근/RCE).
|
||||
// 1.11.0 부터 SuppressPropertiesBeanIntrospector 가 기본 활성이라 declaredClass 접근이 차단된다.
|
||||
// 이 앱의 호출부(PasswordMatchValidator / PasswordRuleValidator / AuthNumberValidator)는
|
||||
// 어노테이션에 박힌 고정 프로퍼티명만 넘기므로 외부 입력 경로는 없지만 버전은 올려 둔다.
|
||||
// 1.11.0 = Java 8 바이트코드(major 52), PropertyUtils.getProperty/getNestedProperty API 동일.
|
||||
implementation('commons-beanutils:commons-beanutils:1.11.0') {
|
||||
// exclude group: 'commons-collections', module: 'commons-collections'
|
||||
}
|
||||
implementation 'org.mapstruct:mapstruct:1.5.5.Final'
|
||||
// WS-2026-0003 (jackson-core async parser DoS, CVSS 7.5) — 2.18.6 에서 수정. JDK8 호환.
|
||||
implementation 'com.fasterxml.jackson.core:jackson-core:2.18.6'
|
||||
implementation 'com.fasterxml.jackson.core:jackson-annotations:2.18.6'
|
||||
implementation 'com.fasterxml.jackson.core:jackson-databind:2.18.6'
|
||||
// jackson 개별 pin 제거: 아래 ext 의 jackson-bom.version 이 전 모듈을 일괄 관리한다.
|
||||
// 개별 pin 은 BOM 보다 우선하므로 남겨 두면 BOM 만 올렸을 때 core/annotations/databind 가
|
||||
// 옛 버전에 고정돼 버전이 어긋난다(실제로 그런 상태였다).
|
||||
|
||||
implementation group: 'org.apache.velocity', name: 'velocity-engine-core', version: '2.3'
|
||||
|
||||
@@ -127,7 +149,10 @@ dependencies {
|
||||
implementation 'net.bytebuddy:byte-buddy:1.14.5'
|
||||
|
||||
// Commons FileUpload (WAS 독립적인 multipart 처리)
|
||||
implementation 'commons-fileupload:commons-fileupload:1.5'
|
||||
// CVE-2025-48976 (멀티파트 파트 헤더 크기 제한 부재 → DoS). 1.6.0 에서 partHeaderSizeMax 도입.
|
||||
// 주의: 1.6 부터 파트 헤더 총량 기본 상한이 10240 → 512 바이트로 줄었다(DEFAULT_PART_HEADER_SIZE_MAX).
|
||||
// 한글 파일명은 UTF-8 로 3바이트/자라 Content-Disposition 이 길어질 수 있어 실측으로 여유를 확인했다.
|
||||
implementation 'commons-fileupload:commons-fileupload:1.6.0'
|
||||
implementation 'commons-io:commons-io:2.15.1'
|
||||
|
||||
|
||||
@@ -145,6 +170,49 @@ ext {
|
||||
springMavenArtifactVersion = '5.3.30'
|
||||
encoding = 'UTF-8'
|
||||
profile = 'local'
|
||||
|
||||
// 내장 Tomcat 버전 상향 (Spring Boot 2.7.18 기본값 9.0.83 → 9.0.120).
|
||||
// Boot BOM 의 tomcat.version 프로퍼티를 덮어써서 tomcat-embed-core/-el/-websocket 이 함께 올라간다.
|
||||
// 9.0.x 계열 유지 = Servlet 4.0 / javax.* 네임스페이스 그대로, JDK8 호환.
|
||||
set('tomcat.version', '9.0.120')
|
||||
|
||||
// CVE-2022-1471 (snakeyaml Constructor 임의 타입 역직렬화 → RCE). Boot 2.7.18 BOM 기본값 1.30 → 2.6.
|
||||
// 앱/프레임워크 실경로는 이미 SafeConstructor 계열(OriginTrackedYamlLoader, spring-beans
|
||||
// FilteringConstructor, swagger-parser DeserializationUtils)이라 익스플로잇 경로는 없었으나
|
||||
// SCA 는 버전으로 판정하므로 2.x 로 올린다. snakeyaml 2.6 = Java 8 바이트코드(major 52).
|
||||
// 2.6 인 이유: swagger-core 2.2.52 / swagger-parser-v3 2.1.45 가 요구하는 버전이라 강등이 없다.
|
||||
// 부수 효과: swagger-parser 가 호출하는 LoaderOptions.setCodePointLimit(1.32+ API) 도 해소.
|
||||
set('snakeyaml.version', '2.6')
|
||||
|
||||
// jackson 전 모듈 버전 통일(Boot 2.7.18 BOM 기본 2.13.5). 2.18.x 는 JDK8 호환 라인이다.
|
||||
// 이유 3가지
|
||||
// 1) snakeyaml 2.x 는 ParserImpl(StreamReader) 를 제거했고 jackson-dataformat-yaml 은
|
||||
// 2.15+ 부터 ParserImpl(StreamReader, LoaderOptions) 를 쓴다 — 위 snakeyaml 상향의 전제.
|
||||
// 2) WS-2026-0003 (jackson-core async parser DoS, CVSS 7.5) — 2.18.6 에서 수정.
|
||||
// 3) jackson-databind PolymorphicTypeValidator 우회(제네릭 타입 인자 미검증) — 2.18.8 에서 수정.
|
||||
// 이 앱은 다형성 역직렬화(activateDefaultTyping/@JsonTypeInfo)를 쓰지 않아 노출 경로는 없다.
|
||||
// 2.18.x 마지막 패치를 쓴다.
|
||||
set('jackson-bom.version', '2.18.10')
|
||||
|
||||
// CVE-2026-41901: ≤3.1.4의 제한된 표현식 구문 검사 우회(SSTI)를 3.1.5에서 수정.
|
||||
// Boot 의존성 관리로 core/spring5를 함께 맞추며 Java8 / Spring5 / javax.servlet을 유지한다.
|
||||
// extras-springsecurity5 3.1.5 / layout-dialect 3.4.0은 유지.
|
||||
// ThymeleafBootMvcCompatibilityTest가 Boot 2.7 자동 구성 엔진·ViewResolver의 초기화,
|
||||
// MVC 폼·레이아웃·보안 표시를 검증한다. 실제 WAS 기동/재배포 검증은 별도 배포 조건이다.
|
||||
// 주의: 3.1 은 #request/#session/#response/#servletContext 표현식 객체를 제거했다(IllegalArgumentException).
|
||||
set('thymeleaf.version', '3.1.5.RELEASE')
|
||||
set('thymeleaf-extras-springsecurity.version', '3.1.5.RELEASE')
|
||||
|
||||
// Spring Framework 5.3.x OSS 마지막 릴리스로 통일(Boot 2.7.18 BOM 기본 5.3.31, 일부 5.3.30 혼재였음).
|
||||
// 5.3.32~5.3.39 구간 CVE 정리용. 5.3.x 는 OSS EOL 이라 이 위로는 상용(Enterprise) 빌드뿐이다.
|
||||
// 남는 5.3.x 지적(CVE-2026-41855 JMS Jackson 역직렬화, CVE-2016-1000027 HttpInvoker)은
|
||||
// 5.3 계열에 수정본이 없고 앱이 JMS/HttpInvoker 를 쓰지 않으므로 억제 항목으로 따로 관리한다.
|
||||
set('spring-framework.version', '5.3.39')
|
||||
|
||||
// Spring Security 5.7.x OSS 마지막 릴리스(Boot 2.7.18 BOM 기본 5.7.11).
|
||||
// CVE-2026-22732(응답 커밋 후 보안 헤더 미기록)는 수정본이 5.7.22/5.8.24 = Enterprise 전용이라
|
||||
// OSS 로는 올릴 수 없다. 우회책(HeaderWriterFilter.shouldWriteHeadersEagerly=true)은 동작 변경이라 분리 검토.
|
||||
set('spring-security.version', '5.7.14')
|
||||
}
|
||||
|
||||
|
||||
@@ -251,6 +319,18 @@ war {
|
||||
classpath = excludeLocalOnlyLibs(classpath)
|
||||
}
|
||||
|
||||
// 정적 리소스만 별도 zip (OHS 추가 배포용). war/bootWar 는 그대로 정적 리소스 포함 유지
|
||||
// (WAS 단독 접속 인원 지원). sass/ 는 컴파일 소스이며 실제 서빙 경로에 없어 제외
|
||||
// (PortalConfigWebDispatcherServlet#addResourceHandlers 기준).
|
||||
task staticResourcesZip(type: Zip) {
|
||||
archiveFileName = "eapim-portal-static.zip"
|
||||
destinationDirectory = file("$buildDir/libs")
|
||||
from('src/main/resources/static') {
|
||||
exclude 'sass/**'
|
||||
}
|
||||
}
|
||||
assemble.dependsOn staticResourcesZip
|
||||
|
||||
task printSourceSets {
|
||||
doLast {
|
||||
sourceSets.each { srcSet ->
|
||||
|
||||
@@ -28,4 +28,94 @@
|
||||
</suppress>
|
||||
-->
|
||||
|
||||
<!-- ================================================================================
|
||||
Spring 계열 Critical 5건 (2026-08-18 리포트 기준).
|
||||
|
||||
공통 배경: Spring Framework 5.3.x / Spring Boot 2.7.x / Spring Security 5.7.x 는 모두 OSS EOL 이라
|
||||
남은 수정본이 상용(Enterprise/Tanzu)뿐이다. JDK 8 유지 제약상 Boot 3 + Spring 6 이관 전까지는
|
||||
버전 상향으로 못 없앤다. 아래는 "코드에 트리거 경로가 없음"을 근거로 한 억제이며 만료일을 둔다.
|
||||
버전 범위를 5.3.x / 2.7.x / 5.7.x 로 고정해 두었으므로, 이관 후에는 억제가 자동으로 풀린다.
|
||||
재검토 시 확인할 것: (1) 각 근거 grep 이 여전히 0건인지 (2) OSS 수정본이 나왔는지.
|
||||
================================================================================ -->
|
||||
|
||||
<!-- 1. Spring Boot: Cassandra SSL 호스트명 미검증 -->
|
||||
<suppress until="2027-02-28Z">
|
||||
<notes><![CDATA[
|
||||
CVE-2026-40974 는 Spring Boot 의 Cassandra SSL 자동설정이 SSL 번들의 호스트명 검증 설정을
|
||||
드라이버에 전달하지 않는 문제다. 이 앱은 Cassandra 를 쓰지 않는다
|
||||
(runtimeClasspath 에 cassandra/datastax 계열 jar 0건, spring-boot-starter-data-cassandra 미선언).
|
||||
수정본 2.7.33 은 상용(Enterprise) 릴리스라 OSS 로는 올릴 수 없다.
|
||||
devtools/actuator/spring-boot-admin jar 는 스캔 대상(runtimeClasspath)에는 있으나
|
||||
배포 산출물에서는 build.gradle 의 localOnlyLibPrefixes 로 제외되어 WAR 에 실리지 않는다.
|
||||
확인: Rinjae / 2026-08-18 / grep -ri cassandra 0건, WAR 내 devtools/actuator jar 0건.
|
||||
]]></notes>
|
||||
<packageUrl regex="true">^pkg:maven/org\.springframework\.boot/.*@2\.7\..*$</packageUrl>
|
||||
<cve>CVE-2026-40974</cve>
|
||||
</suppress>
|
||||
|
||||
<!-- 2. Thymeleaf SSTI 2건: starter jar 이름 기준 오탐 -->
|
||||
<suppress>
|
||||
<notes><![CDATA[
|
||||
오탐. CVE-2026-40477 / CVE-2026-40478 은 thymeleaf 본체 3.1.3.RELEASE 이하의 표현식 샌드박스
|
||||
우회 문제이고 3.1.4.RELEASE 에서 수정됐다. 이 프로젝트는 build.gradle 의 ext 에서
|
||||
thymeleaf.version=3.1.4.RELEASE / thymeleaf-extras-springsecurity.version=3.1.5.RELEASE 로
|
||||
올려 두었으므로 실제 실리는 jar 는 thymeleaf-3.1.4.RELEASE.jar 다.
|
||||
Dependency-Check 가 spring-boot-starter-thymeleaf-2.7.18.jar(의존만 선언한 빈 starter)에
|
||||
thymeleaf CPE 를 잘못 매칭한 결과다. thymeleaf 본체 jar 에 대한 탐지는 억제하지 않는다.
|
||||
확인: Rinjae / 2026-08-18 / WAR 내 thymeleaf-3.1.4.RELEASE.jar, thymeleaf-spring5-3.1.4.RELEASE.jar.
|
||||
]]></notes>
|
||||
<packageUrl regex="true">^pkg:maven/org\.springframework\.boot/spring-boot-starter-thymeleaf@.*$</packageUrl>
|
||||
<cve>CVE-2026-40477</cve>
|
||||
<cve>CVE-2026-40478</cve>
|
||||
</suppress>
|
||||
|
||||
<!-- 3. Spring Framework: JMS Jackson 역직렬화 -->
|
||||
<suppress until="2027-02-28Z">
|
||||
<notes><![CDATA[
|
||||
CVE-2026-41855 는 org.springframework.jms.support.converter.MappingJackson2MessageConverter /
|
||||
JacksonJsonMessageConverter 가 임의 클래스 인스턴스화를 허용하는 문제다(신뢰할 수 없는 JMS 환경 전제).
|
||||
이 앱은 JMS 를 쓰지 않는다: runtimeClasspath 에 spring-jms 0건, 소스에 javax.jms /
|
||||
JmsTemplate / MappingJackson2MessageConverter 참조 0건.
|
||||
5.3.x 는 OSS 수정본이 없다(6.2.19 / 7.0.8 에서만 수정). 이미 5.3.x OSS 마지막인 5.3.39 로 올려 둔 상태다.
|
||||
확인: Rinjae / 2026-08-18 / grep -rn "javax.jms|JmsTemplate|MappingJackson2MessageConverter" src 0건.
|
||||
]]></notes>
|
||||
<packageUrl regex="true">^pkg:maven/org\.springframework/spring-.*@5\.3\..*$</packageUrl>
|
||||
<cve>CVE-2026-41855</cve>
|
||||
</suppress>
|
||||
|
||||
<!-- 4. spring-web: HttpInvoker 역직렬화 -->
|
||||
<suppress until="2027-02-28Z">
|
||||
<notes><![CDATA[
|
||||
CVE-2016-1000027 은 HttpInvokerServiceExporter 를 노출했을 때만 성립한다. 해당 클래스는
|
||||
Spring 6.0 에서 제거됐고 5.3.x 에는 수정본이 없다(= 5.3.x 를 쓰는 한 계속 탐지된다).
|
||||
이 앱은 HttpInvoker 계열을 쓰지 않는다: 소스에 HttpInvoker 참조 0건이고 외부 호출은
|
||||
RestTemplate / HttpClient 기반이다.
|
||||
근본 해결은 Spring 6(Boot 3, JDK 17) 이관. 이관 전까지 억제한다.
|
||||
확인: Rinjae / 2026-08-18 / grep -rn HttpInvoker src 0건.
|
||||
]]></notes>
|
||||
<packageUrl regex="true">^pkg:maven/org\.springframework/spring-web@5\.3\..*$</packageUrl>
|
||||
<cve>CVE-2016-1000027</cve>
|
||||
</suppress>
|
||||
|
||||
<!-- 5. Spring Security: 응답 커밋 시 보안 헤더 미기록 -->
|
||||
<suppress until="2027-02-28Z">
|
||||
<notes><![CDATA[
|
||||
CVE-2026-22732 는 응답이 커밋된 뒤 Spring Security 가 보안 헤더를 기록하지 못하는 문제다.
|
||||
트리거는 Content-Length 를 setHeader / setIntHeader / addIntHeader 로 지정하는 경로다
|
||||
(OnCommittedResponseWrapper 는 setContentLength / setContentLengthLong / addHeader 만 추적한다).
|
||||
(1) 이 코드베이스에는 트리거가 없다: portal / elink-portal-common / elink-online-core-jpa 전체에
|
||||
setIntHeader, addIntHeader, setHeader("Content-Length") 0건. 파일 다운로드는
|
||||
response.setContentLength(int) 를 쓰며 실측상 보안 헤더가 정상 기록된다.
|
||||
(2) 그럼에도 안전망으로 PortalConfigSecurity 에서
|
||||
HeaderWriterFilter.setShouldWriteHeadersEagerly(true) 를 적용해(spring.io 권고 우회책)
|
||||
요청 시작 시점에 헤더를 기록하도록 했다.
|
||||
수정본 5.7.22 / 5.8.24 는 Enterprise 전용이라 OSS 로는 올릴 수 없다. 이미 5.7.x OSS 마지막인
|
||||
5.7.14 로 올려 둔 상태다.
|
||||
확인: Rinjae / 2026-08-18 / 동일 스택(Boot 2.7.18 + Security 5.7.14 + Tomcat 9.0.120) 프로브 앱 실측 —
|
||||
우회책 미적용 시 setHeader/setIntHeader/addIntHeader 경로에서 헤더 누락 재현, 적용 후 정상 기록.
|
||||
]]></notes>
|
||||
<packageUrl regex="true">^pkg:maven/org\.springframework\.security/spring-security-.*@5\.7\..*$</packageUrl>
|
||||
<cve>CVE-2026-22732</cve>
|
||||
</suppress>
|
||||
|
||||
</suppressions>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
## OHS Static Resource 설정 예시
|
||||
|
||||
```apacheconf
|
||||
<VirtualHost *:443>
|
||||
ServerName weblogic-djb.rinjae.kr
|
||||
|
||||
<IfModule ossl_module>
|
||||
SSLEngine on
|
||||
SSLVerifyClient None
|
||||
SSLProtocol TLSv1.2 TLSv1.3
|
||||
SSLHonorCipherOrder on
|
||||
SSLCipherSuite TLS_AES_128_GCM_SHA256,TLS_AES_256_GCM_SHA384,TLS_CHACHA20_POLY1305_SHA256,TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
|
||||
SSLWallet "${ORACLE_INSTANCE}/config/fmwconfig/components/${COMPONENT_TYPE}/instances/${COMPONENT_NAME}/keystores/default"
|
||||
|
||||
<FilesMatch "\.(cgi|shtml|phtml|php)$">
|
||||
SSLOptions +StdEnvVars
|
||||
</FilesMatch>
|
||||
|
||||
<Directory "${ORACLE_INSTANCE}/config/fmwconfig/components/${COMPONENT_TYPE}/instances/${COMPONENT_NAME}/cgi-bin">
|
||||
SSLOptions +StdEnvVars
|
||||
</Directory>
|
||||
|
||||
BrowserMatch "MSIE [2-5]" \
|
||||
nokeepalive ssl-unclean-shutdown \
|
||||
downgrade-1.0 force-response-1.0
|
||||
|
||||
<IfModule mod_headers.c>
|
||||
Header always set Strict-Transport-Security "max-age=63072000; preload; includeSubDomains"
|
||||
</IfModule>
|
||||
</IfModule>
|
||||
|
||||
Alias /css /apps/portal-static/css
|
||||
Alias /js /apps/portal-static/js
|
||||
Alias /img /apps/portal-static/img
|
||||
Alias /images /apps/portal-static/images
|
||||
Alias /webfonts /apps/portal-static/webfonts
|
||||
Alias /font /apps/portal-static/font
|
||||
Alias /html /apps/portal-static/html
|
||||
Alias /plugins /apps/portal-static/plugins
|
||||
Alias /favicon.png /apps/portal-static/favicon.png
|
||||
|
||||
<Directory "/apps/portal-static">
|
||||
Require all granted
|
||||
Options -Indexes
|
||||
</Directory>
|
||||
|
||||
<LocationMatch "^/favicon\.png$|^/(css|js|img|images|webfonts|font|html|plugins)/">
|
||||
Header set Cache-Control "no-cache"
|
||||
</LocationMatch>
|
||||
|
||||
<IfModule weblogic_module>
|
||||
<Location />
|
||||
DirectoryIndex disabled
|
||||
SetHandler weblogic-handler
|
||||
WebLogicHost 172.30.1.100
|
||||
WebLogicPort 39130
|
||||
DynamicServerList OFF
|
||||
ConnectTimeoutSecs 10
|
||||
ConnectRetrySecs 2
|
||||
WLProxySSL ON
|
||||
</Location>
|
||||
</IfModule>
|
||||
|
||||
<Location /css>
|
||||
SetHandler None
|
||||
</Location>
|
||||
<Location /js>
|
||||
SetHandler None
|
||||
</Location>
|
||||
<Location /img>
|
||||
SetHandler None
|
||||
</Location>
|
||||
<Location /images>
|
||||
SetHandler None
|
||||
</Location>
|
||||
<Location /webfonts>
|
||||
SetHandler None
|
||||
</Location>
|
||||
<Location /font>
|
||||
SetHandler None
|
||||
</Location>
|
||||
<Location /html>
|
||||
SetHandler None
|
||||
</Location>
|
||||
<Location /plugins>
|
||||
SetHandler None
|
||||
</Location>
|
||||
<Location /favicon.png>
|
||||
SetHandler None
|
||||
</Location>
|
||||
</VirtualHost>
|
||||
```
|
||||
@@ -0,0 +1,343 @@
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
import http.cookiejar
|
||||
from http.cookies import SimpleCookie
|
||||
import json
|
||||
import ssl
|
||||
import sys
|
||||
import time
|
||||
import unicodedata
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
DEFAULT_HOST = 'https://api.jejubank.co.kr'
|
||||
COOKIE_NAME = 'JSESSIONID_PORTAL'
|
||||
USER_AGENT = (
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
|
||||
'AppleWebKit/537.36 (KHTML, like Gecko) '
|
||||
'Chrome/140.0.0.0 Safari/537.36'
|
||||
)
|
||||
COLUMNS = (
|
||||
('회', 3),
|
||||
('대상', 4),
|
||||
('결과', 10),
|
||||
('쿠키', 8),
|
||||
('CSRF', 8),
|
||||
('HTTP', 4),
|
||||
('요청 값', 10),
|
||||
('요청 서버ID', 11),
|
||||
('응답 값', 10),
|
||||
('응답 서버ID', 11),
|
||||
)
|
||||
BORDER = '+' + '+'.join('-' * (width + 2) for _, width in COLUMNS) + '+'
|
||||
|
||||
|
||||
class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, request, fp, code, message, headers, new_url):
|
||||
# 두 서버 비교에서는 지정한 주소의 첫 응답만 검사한다.
|
||||
# 공유 세션 쿠키를 리다이렉트 대상에 전달하지 않는다.
|
||||
return None
|
||||
|
||||
|
||||
def integer_between(minimum, maximum):
|
||||
def parse(value):
|
||||
try:
|
||||
number = int(value)
|
||||
except ValueError:
|
||||
raise argparse.ArgumentTypeError(
|
||||
f'{minimum}~{maximum} 사이의 정수를 입력하세요.'
|
||||
)
|
||||
if not minimum <= number <= maximum:
|
||||
raise argparse.ArgumentTypeError(
|
||||
f'{minimum}~{maximum} 사이의 정수를 입력하세요.'
|
||||
)
|
||||
return number
|
||||
return parse
|
||||
|
||||
|
||||
def host_address(value):
|
||||
value = value.strip()
|
||||
message = 'http://호스트[:포트] 또는 https://호스트[:포트] 형식으로 입력하세요.'
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
parsed.port # 포트 형식과 범위도 검사한다.
|
||||
except ValueError:
|
||||
raise argparse.ArgumentTypeError(message)
|
||||
if (parsed.scheme not in ('http', 'https') or not parsed.hostname
|
||||
or parsed.path not in ('', '/') or parsed.query or parsed.fragment
|
||||
or parsed.username is not None or parsed.password is not None
|
||||
or any(char.isspace() for char in value)):
|
||||
raise argparse.ArgumentTypeError(message)
|
||||
return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, '', '', ''))
|
||||
|
||||
|
||||
def parse_args(argv=None):
|
||||
parser = argparse.ArgumentParser(
|
||||
description='새 익명 세션으로 쿠키와 CSRF 토큰의 유지 여부를 표로 확인합니다.'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--host', type=host_address, default=DEFAULT_HOST,
|
||||
metavar='주소', help=f'대상 호스트 주소 (기본값: {DEFAULT_HOST})',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--host2', type=host_address, metavar='주소',
|
||||
help='두 번째 서버. A/B를 번갈아 요청 (리다이렉트 미추적)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--cookie-mode', choices=('shared', 'separate'), default='shared',
|
||||
help='shared: 직전 세션 쿠키 공유, separate: 서버 주소/포트별 저장소 분리 (기본값: shared)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-n', '--count', type=integer_between(1, 100), default=10,
|
||||
metavar='횟수', help='전체 요청 횟수: 1~100회 (기본값: 10회, 두 서버 모드도 합산)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-d', '--delay', '--delay-ms', type=integer_between(0, 10000), default=50,
|
||||
metavar='밀리초', help='요청 사이 대기시간: 0~10000ms (기본값: 50ms)',
|
||||
)
|
||||
tls_options = parser.add_mutually_exclusive_group()
|
||||
tls_options.add_argument(
|
||||
'--cacert', metavar='CA파일',
|
||||
help='신뢰할 사설 CA 인증서 또는 인증서 묶음 파일 (PEM 형식)',
|
||||
)
|
||||
tls_options.add_argument(
|
||||
'-k', '--insecure', action='store_true',
|
||||
help='HTTPS 서버 인증서와 호스트명 검증 생략',
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def tls_context(args):
|
||||
context = ssl.create_default_context()
|
||||
if args.cacert:
|
||||
context.load_verify_locations(cafile=args.cacert)
|
||||
if args.insecure:
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
return context
|
||||
|
||||
|
||||
def cookie_from_headers(headers):
|
||||
value = None
|
||||
for header in headers:
|
||||
parsed = SimpleCookie()
|
||||
parsed.load(header)
|
||||
if COOKIE_NAME in parsed:
|
||||
value = parsed[COOKIE_NAME].value
|
||||
return value
|
||||
|
||||
|
||||
def cookie_for_url(jar, url):
|
||||
request = urllib.request.Request(url)
|
||||
jar.add_cookie_header(request)
|
||||
return cookie_from_headers([request.get_header('Cookie', '')])
|
||||
|
||||
|
||||
def host_key(url):
|
||||
parsed = urllib.parse.urlsplit(url)
|
||||
port = parsed.port if parsed.port is not None else (443 if parsed.scheme == 'https' else 80)
|
||||
return parsed.scheme, parsed.hostname, port
|
||||
|
||||
|
||||
def carry_session_cookie(request, value):
|
||||
# 현재 호스트에 해당하는 다른 쿠키는 유지하고 포털 세션 쿠키만 이어 보낸다.
|
||||
cookies = SimpleCookie()
|
||||
cookies.load(request.get_header('Cookie', ''))
|
||||
cookies[COOKIE_NAME] = value
|
||||
request.add_unredirected_header(
|
||||
'Cookie', cookies.output(header='', sep='; ').strip()
|
||||
)
|
||||
|
||||
|
||||
def server_id(cookie):
|
||||
return cookie.split('!')[1] if cookie and '!' in cookie else '-'
|
||||
|
||||
|
||||
def cell(value, width):
|
||||
text = str(value)
|
||||
# 한글은 보통 터미널에서 두 칸을 차지하므로 표시 폭을 기준으로 정렬한다.
|
||||
display_width = sum(
|
||||
0 if unicodedata.combining(char) else
|
||||
2 if unicodedata.east_asian_width(char) in ('W', 'F') else 1
|
||||
for char in text
|
||||
)
|
||||
return text + ' ' * max(0, width - display_width)
|
||||
|
||||
|
||||
def print_row(values):
|
||||
print('| ' + ' | '.join(
|
||||
cell(value, width) for value, (_, width) in zip(values, COLUMNS)
|
||||
) + ' |', flush=True)
|
||||
|
||||
|
||||
def change_state(previous, current):
|
||||
if previous is None:
|
||||
return '최초'
|
||||
return '유지' if previous == current else '!!변경!!'
|
||||
|
||||
|
||||
def run_probe(args):
|
||||
targets = [('A', args.host + '/api/session/csrf')]
|
||||
if args.host2:
|
||||
targets.append(('B', args.host2 + '/api/session/csrf'))
|
||||
try:
|
||||
context = tls_context(args)
|
||||
except (OSError, ValueError) as error:
|
||||
print(f'TLS 설정 실패: {error}', file=sys.stderr)
|
||||
return 1
|
||||
jar = http.cookiejar.CookieJar()
|
||||
cookie_processor = urllib.request.HTTPCookieProcessor(jar)
|
||||
handlers = [
|
||||
urllib.request.HTTPSHandler(context=context),
|
||||
cookie_processor,
|
||||
]
|
||||
if args.host2:
|
||||
handlers.append(NoRedirectHandler())
|
||||
client = urllib.request.build_opener(*handlers)
|
||||
previous_cookie = None
|
||||
previous_token = None
|
||||
cookie_stores = {}
|
||||
session_states = {}
|
||||
totals = {'최초': 0, '유지': 0, '변경': 0}
|
||||
completed = 0
|
||||
failure = None
|
||||
exit_code = 0
|
||||
|
||||
for target, url in targets:
|
||||
print(f'대상 {target}: {url}')
|
||||
if len(targets) == 2:
|
||||
print('호출 순서: A -> B -> A -> B ... (클라이언트 1개, 전체 요청 횟수 기준)')
|
||||
print('두 서버 비교에서는 3xx 리다이렉트를 따라가지 않습니다.')
|
||||
if args.cookie_mode == 'separate':
|
||||
print('쿠키 모드: separate | 서버 주소/포트별 저장소 및 변경 비교 기준 분리')
|
||||
elif len(targets) == 2:
|
||||
print(f'쿠키 모드: shared | 직전 {COOKIE_NAME}을 다음 서버로 전달')
|
||||
if any(url.startswith('https://') for _, url in targets):
|
||||
tls_mode = ('생략 (--insecure)' if args.insecure else
|
||||
f'사설 CA 추가 ({args.cacert})' if args.cacert else '기본 CA 사용')
|
||||
print(f'TLS 인증서 검증: {tls_mode}')
|
||||
print(f'쿠키 키: {COOKIE_NAME} | 값: 앞 10자리 | 횟수: {args.count}회 | 딜레이: {args.delay}ms')
|
||||
print(BORDER)
|
||||
print_row([title for title, _ in COLUMNS])
|
||||
print(BORDER, flush=True)
|
||||
|
||||
try:
|
||||
for number in range(1, args.count + 1):
|
||||
if number > 1 and args.delay:
|
||||
time.sleep(args.delay / 1000)
|
||||
|
||||
target, url = targets[(number - 1) % len(targets)]
|
||||
if args.cookie_mode == 'separate':
|
||||
key = host_key(url)
|
||||
if key not in cookie_stores:
|
||||
cookie_stores[key] = http.cookiejar.CookieJar()
|
||||
jar = cookie_stores[key]
|
||||
# 순차 요청마다 같은 클라이언트의 쿠키 저장소만 교체한다.
|
||||
cookie_processor.cookiejar = jar
|
||||
previous_cookie, previous_token = session_states.get(key, (None, None))
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
'Accept': 'application/json',
|
||||
'Cache-Control': 'no-cache',
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
)
|
||||
# 응답이 저장소를 갱신하기 전에 실제 전송할 쿠키를 기록한다.
|
||||
jar.add_cookie_header(request)
|
||||
if args.host2 and args.cookie_mode == 'shared' and previous_cookie is not None:
|
||||
carry_session_cookie(request, previous_cookie)
|
||||
request_cookie = cookie_from_headers([request.get_header('Cookie', '')])
|
||||
response_cookie = None
|
||||
status = '-'
|
||||
try:
|
||||
with client.open(request, timeout=10) as response:
|
||||
status = response.status
|
||||
response_url = response.geturl()
|
||||
# 이번 응답의 Set-Cookie만 표시한다. 재발급이 없으면 '없음'.
|
||||
response_cookie = cookie_from_headers(
|
||||
response.headers.get_all('Set-Cookie') or []
|
||||
)
|
||||
data = json.load(response)
|
||||
|
||||
if args.host2 and args.cookie_mode == 'shared' and response_cookie is None:
|
||||
# 다른 호스트에 직접 이어 보낸 쿠키는 저장소에 없을 수 있다.
|
||||
# 재발급이 없으면 이번 요청에 실었던 세션을 계속 사용한다.
|
||||
cookie = request_cookie
|
||||
else:
|
||||
cookie = cookie_for_url(jar, response_url)
|
||||
token = data.get('token') if isinstance(data, dict) else None
|
||||
if not cookie or not token:
|
||||
raise ValueError('세션 쿠키 또는 CSRF 토큰 없음')
|
||||
except urllib.error.HTTPError as error:
|
||||
status = error.code
|
||||
response_cookie = cookie_from_headers(
|
||||
error.headers.get_all('Set-Cookie') or []
|
||||
)
|
||||
failure = f'{number}회: HTTP {status}'
|
||||
error.close()
|
||||
except Exception as error:
|
||||
failure = f'{number}회: {error}'
|
||||
|
||||
if failure:
|
||||
result, cookie_state, token_state = '!!실패!!', '-', '-'
|
||||
exit_code = 1
|
||||
else:
|
||||
# 변경 판정은 앞 10자리가 아니라 전체 쿠키와 전체 토큰으로 비교한다.
|
||||
cookie_state = change_state(previous_cookie, cookie)
|
||||
token_state = change_state(previous_token, token)
|
||||
if '!!변경!!' in (cookie_state, token_state):
|
||||
outcome, result = '변경', '>>>변경<<<'
|
||||
elif previous_cookie is None:
|
||||
outcome, result = '최초', '[최초]'
|
||||
else:
|
||||
outcome, result = '유지', '[유지]'
|
||||
totals[outcome] += 1
|
||||
completed += 1
|
||||
previous_cookie, previous_token = cookie, token
|
||||
if args.cookie_mode == 'separate':
|
||||
session_states[host_key(url)] = (cookie, token)
|
||||
|
||||
print_row((
|
||||
number, target, result, cookie_state, token_state, status,
|
||||
request_cookie[:10] if request_cookie else '없음',
|
||||
server_id(request_cookie),
|
||||
response_cookie[:10] if response_cookie else '없음',
|
||||
server_id(response_cookie),
|
||||
))
|
||||
if failure:
|
||||
break
|
||||
except KeyboardInterrupt:
|
||||
failure = '사용자가 중단했습니다.'
|
||||
exit_code = 130
|
||||
|
||||
print(BORDER)
|
||||
print(f'정상 조회: {completed}/{args.count}회 | 최초: {totals["최초"]}회 | 유지: {totals["유지"]}회 | 변경: {totals["변경"]}회')
|
||||
print('응답 값=없음: Set-Cookie 재발급 없음. 서버ID는 각 쿠키의 ! 뒤 식별값입니다.')
|
||||
if failure:
|
||||
print(f'실패/중단: {failure}')
|
||||
return exit_code
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
args = parse_args(argv)
|
||||
started_counter = time.perf_counter()
|
||||
started_at = datetime.now().astimezone()
|
||||
print(f'시작 시각: {started_at.isoformat(sep=" ", timespec="milliseconds")}', flush=True)
|
||||
try:
|
||||
return run_probe(args)
|
||||
except KeyboardInterrupt:
|
||||
print('실패/중단: 사용자가 중단했습니다.')
|
||||
return 130
|
||||
finally:
|
||||
finished_at = datetime.now().astimezone()
|
||||
# 시스템 시각 보정에 영향받지 않도록 경과 시간은 별도 시계로 측정한다.
|
||||
elapsed = time.perf_counter() - started_counter
|
||||
print(f'종료 시각: {finished_at.isoformat(sep=" ", timespec="milliseconds")}')
|
||||
print(f'총 소요 시간: {elapsed:.3f}초 (요청 간 딜레이 포함)', flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,21 @@
|
||||
// ./gradlew -I gradle/thymeleaf-verification.init.gradle :thymeleafCompatibilityDependencies
|
||||
// Read-only dependency resolution; does not override versions or the normal build.
|
||||
gradle.projectsEvaluated {
|
||||
def portal = gradle.rootProject
|
||||
portal.tasks.register('thymeleafCompatibilityDependencies') {
|
||||
doLast {
|
||||
def output = new File(portal.buildDir, 'reports/thymeleaf-compatibility')
|
||||
output.mkdirs()
|
||||
['runtimeClasspath', 'testRuntimeClasspath'].each { name ->
|
||||
def artifacts = portal.configurations.getByName(name).resolvedConfiguration.resolvedArtifacts
|
||||
def rows = artifacts.findAll {
|
||||
it.id.componentIdentifier instanceof org.gradle.api.artifacts.component.ModuleComponentIdentifier
|
||||
}.collect {
|
||||
"${it.moduleVersion.id.group}:${it.name}\t${it.moduleVersion.id.version}\t${it.file.name}"
|
||||
}.sort()
|
||||
new File(output, "${name}.tsv").text = rows.join('\n') + '\n'
|
||||
}
|
||||
println "Dependency evidence: ${output}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare resolved dependency TSVs and inspect both WARs; exits nonzero on regression."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import zipfile
|
||||
|
||||
|
||||
EXPECTED_CHANGES = {
|
||||
"org.thymeleaf:thymeleaf": ("3.1.4.RELEASE", "3.1.5.RELEASE"),
|
||||
"org.thymeleaf:thymeleaf-spring5": ("3.1.4.RELEASE", "3.1.5.RELEASE"),
|
||||
"ognl:ognl": ("3.3.4", "3.3.5"),
|
||||
}
|
||||
EXPECTED_CLASSES = {
|
||||
"org/thymeleaf/TemplateEngine.class": "thymeleaf-3.1.5.RELEASE.jar",
|
||||
"org/thymeleaf/spring5/SpringTemplateEngine.class": "thymeleaf-spring5-3.1.5.RELEASE.jar",
|
||||
"ognl/Ognl.class": "ognl-3.3.5.jar",
|
||||
}
|
||||
LOCAL_PREFIXES = (
|
||||
"spring-boot-devtools", "spring-boot-starter-actuator", "spring-boot-actuator",
|
||||
"micrometer-", "spring-boot-admin-", "tomcat-embed-websocket-",
|
||||
)
|
||||
|
||||
|
||||
def require(condition, message):
|
||||
if not condition:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def dependencies(path):
|
||||
result = {}
|
||||
for line in path.read_text().splitlines():
|
||||
module, version, filename = line.split("\t")
|
||||
entries = result.setdefault(module, [])
|
||||
require((version, filename) not in entries, "Duplicate artifact: " + filename)
|
||||
require(not entries or entries[0][0] == version, "Multiple versions: " + module)
|
||||
entries.append((version, filename))
|
||||
for entries in result.values():
|
||||
entries.sort()
|
||||
return result
|
||||
|
||||
|
||||
def compare(before, after):
|
||||
old, new = dependencies(before), dependencies(after)
|
||||
require(old.keys() == new.keys(), "Added/removed external modules: " + str(old.keys() ^ new.keys()))
|
||||
changed = {key: (old[key][0][0], new[key][0][0]) for key in old if old[key] != new[key]}
|
||||
require(changed == EXPECTED_CHANGES, "Unexpected dependency changes: " + str(changed))
|
||||
return {"external_modules": len(new), "changes": changed}
|
||||
|
||||
|
||||
def inspect_war(path):
|
||||
owners = {name: [] for name in EXPECTED_CLASSES}
|
||||
with zipfile.ZipFile(path) as war:
|
||||
names = war.namelist()
|
||||
require(len(names) == len(set(names)), "Duplicate ZIP entries: " + str(path))
|
||||
jars = sorted(name for name in names if name.endswith(".jar"))
|
||||
basenames = [Path(name).name for name in jars]
|
||||
require(len(basenames) == len(set(basenames)), "Duplicate JAR names: " + str(path))
|
||||
require(not any(Path(name).name.startswith("application-local") and name.endswith(".yml")
|
||||
for name in names), "Local profile packaged: " + str(path))
|
||||
for name in jars:
|
||||
basename = Path(name).name
|
||||
require(not basename.startswith(LOCAL_PREFIXES), "Excluded library packaged: " + name)
|
||||
require(not re.match(r"spring-[\w-]+-6\.", basename), "Spring 6 packaged: " + name)
|
||||
with zipfile.ZipFile(io.BytesIO(war.read(name))) as jar:
|
||||
classes = set(jar.namelist())
|
||||
require(not any(c.startswith("jakarta/servlet/") for c in classes),
|
||||
"Jakarta Servlet classes packaged: " + name)
|
||||
for target in owners:
|
||||
if target in classes:
|
||||
owners[target].append(name)
|
||||
# Java 8 compatibility of each upgraded library's entry class.
|
||||
require(int.from_bytes(jar.read(target)[6:8], "big") <= 52,
|
||||
"Java >8 class: " + name + "!" + target)
|
||||
for target, expected in EXPECTED_CLASSES.items():
|
||||
require(owners[target] == ["WEB-INF/lib/" + expected],
|
||||
"Wrong/duplicate class provider: " + target + " " + str(owners[target]))
|
||||
for pattern, expected in [
|
||||
(r"thymeleaf-\d", "thymeleaf-3.1.5.RELEASE.jar"),
|
||||
(r"thymeleaf-spring\d-", "thymeleaf-spring5-3.1.5.RELEASE.jar"),
|
||||
(r"ognl-", "ognl-3.3.5.jar"),
|
||||
]:
|
||||
require([n for n in basenames if re.match(pattern, n)] == [expected],
|
||||
"Wrong/duplicate library version for " + expected)
|
||||
return {"file": path.name, "sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
|
||||
"jar_count": len(jars), "class_providers": owners, "jars": jars}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--baseline", type=Path, required=True, help="Directory of baseline dependency TSVs")
|
||||
parser.add_argument("--current", type=Path, required=True, help="Directory of current dependency TSVs")
|
||||
parser.add_argument("--war", type=Path, action="append", required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
require(len(args.war) == 2 and len(set(args.war)) == 2, "Provide the standard WAR and bootWar")
|
||||
result = {
|
||||
"dependencies": {name: compare(args.baseline / (name + ".tsv"), args.current / (name + ".tsv"))
|
||||
for name in ["runtimeClasspath", "testRuntimeClasspath"]},
|
||||
"wars": [inspect_war(path) for path in args.war],
|
||||
}
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n")
|
||||
print("PASS: dependency changes limited to three modules; both WARs verified")
|
||||
for war in result["wars"]:
|
||||
print(war["file"], war["sha256"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Generated
+542
@@ -9,9 +9,454 @@
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.63.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"node-forge": "^1.3.1",
|
||||
"sass": "^1.69.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
|
||||
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
|
||||
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
|
||||
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
|
||||
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
|
||||
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
|
||||
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
|
||||
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
|
||||
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
|
||||
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
||||
@@ -322,6 +767,22 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||
@@ -349,6 +810,48 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.25.12",
|
||||
"@esbuild/android-arm": "0.25.12",
|
||||
"@esbuild/android-arm64": "0.25.12",
|
||||
"@esbuild/android-x64": "0.25.12",
|
||||
"@esbuild/darwin-arm64": "0.25.12",
|
||||
"@esbuild/darwin-x64": "0.25.12",
|
||||
"@esbuild/freebsd-arm64": "0.25.12",
|
||||
"@esbuild/freebsd-x64": "0.25.12",
|
||||
"@esbuild/linux-arm": "0.25.12",
|
||||
"@esbuild/linux-arm64": "0.25.12",
|
||||
"@esbuild/linux-ia32": "0.25.12",
|
||||
"@esbuild/linux-loong64": "0.25.12",
|
||||
"@esbuild/linux-mips64el": "0.25.12",
|
||||
"@esbuild/linux-ppc64": "0.25.12",
|
||||
"@esbuild/linux-riscv64": "0.25.12",
|
||||
"@esbuild/linux-s390x": "0.25.12",
|
||||
"@esbuild/linux-x64": "0.25.12",
|
||||
"@esbuild/netbsd-arm64": "0.25.12",
|
||||
"@esbuild/netbsd-x64": "0.25.12",
|
||||
"@esbuild/openbsd-arm64": "0.25.12",
|
||||
"@esbuild/openbsd-x64": "0.25.12",
|
||||
"@esbuild/openharmony-arm64": "0.25.12",
|
||||
"@esbuild/sunos-x64": "0.25.12",
|
||||
"@esbuild/win32-arm64": "0.25.12",
|
||||
"@esbuild/win32-ia32": "0.25.12",
|
||||
"@esbuild/win32-x64": "0.25.12"
|
||||
}
|
||||
},
|
||||
"node_modules/immutable": {
|
||||
"version": "5.1.5",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
|
||||
@@ -389,6 +892,16 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/node-forge": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
|
||||
"integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
|
||||
"dev": true,
|
||||
"license": "(BSD-3-Clause OR GPL-2.0)",
|
||||
"engines": {
|
||||
"node": ">= 6.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
@@ -403,6 +916,35 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||
|
||||
+7
-2
@@ -1,15 +1,20 @@
|
||||
{
|
||||
"name": "eapim-portal",
|
||||
"version": "1.0.0",
|
||||
"description": "SASS build system for EAPIM Portal",
|
||||
"description": "SASS build system + forge custom bundle for EAPIM Portal",
|
||||
"scripts": {
|
||||
"test:moment": "playwright test --config=src/test/js/playwright.config.js",
|
||||
"sass:build": "sass src/main/resources/static/sass/main.scss:src/main/resources/static/css/main.css --style=expanded",
|
||||
"sass:build:minified": "sass src/main/resources/static/sass/main.scss:src/main/resources/static/css/main.min.css --style=compressed",
|
||||
"sass:watch": "sass --watch src/main/resources/static/sass/main.scss:src/main/resources/static/css/main.css --style=expanded",
|
||||
"build": "npm run sass:build && npm run sass:build:minified",
|
||||
"dev": "npm run sass:watch"
|
||||
"dev": "npm run sass:watch",
|
||||
"forge:build": "esbuild tools/forge-entry.js --bundle --minify --format=iife --global-name=forge --target=es5 --outfile=src/main/resources/static/js/lib/forge-crypto.min.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.63.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"node-forge": "^1.3.1",
|
||||
"sass": "^1.69.5"
|
||||
},
|
||||
"author": "",
|
||||
|
||||
@@ -5,19 +5,36 @@ import java.sql.SQLException;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.thymeleaf.spring5.SpringTemplateEngine;
|
||||
|
||||
import com.google.common.util.concurrent.Futures;
|
||||
|
||||
/**
|
||||
* Readiness probe.
|
||||
*
|
||||
* <p>Distinct from {@link HealthCheckController} (liveness — servlet alive?).
|
||||
* This endpoint validates both EMS and Gateway datasources via JDBC
|
||||
* {@code Connection.isValid(timeout)} to confirm the app is ready to serve
|
||||
* requests that depend on the database.
|
||||
* {@code Connection.isValid(timeout)}, plus three libraries that WebLogic's
|
||||
* oracle_common bundles at a different version from this app (see weblogic.xml
|
||||
* {@code prefer-application-packages}): Thymeleaf's {@link SpringTemplateEngine}
|
||||
* (jackson), jboss-logging, and Guava.
|
||||
*
|
||||
* <p>These exist because this controller bypasses view resolution entirely
|
||||
* (plain {@code @RestController} JSON) — a classpath split between the app's
|
||||
* copy and WebLogic's bundled copy of a preferred package can throw
|
||||
* {@link LinkageError}/{@link java.util.ServiceConfigurationError} only on the
|
||||
* real code paths that touch it, while this probe kept reporting 200. That
|
||||
* already happened once for jackson (Thymeleaf's dialect/module discovery
|
||||
* failed on every real page while /health/ready stayed green) — jboss-logging
|
||||
* and Guava carry the same risk (confirmed version mismatch against
|
||||
* oracle_common, not yet observed failing in production) so they get the same
|
||||
* kind of forced-touch check here.
|
||||
*
|
||||
* <p>HTTP 200 + JSON when all checks pass.
|
||||
* <p>HTTP 503 + JSON when any check fails — body still includes the per-component
|
||||
@@ -30,24 +47,34 @@ public class ReadinessController {
|
||||
|
||||
private final DataSource portalDataSource;
|
||||
private final DataSource gatewayDataSource;
|
||||
private final SpringTemplateEngine templateEngine;
|
||||
|
||||
public ReadinessController(
|
||||
@Qualifier("portalDataSource") DataSource portalDataSource,
|
||||
@Qualifier("gatewayDataSource") DataSource gatewayDataSource) {
|
||||
@Qualifier("gatewayDataSource") DataSource gatewayDataSource,
|
||||
SpringTemplateEngine templateEngine) {
|
||||
this.portalDataSource = portalDataSource;
|
||||
this.gatewayDataSource = gatewayDataSource;
|
||||
this.templateEngine = templateEngine;
|
||||
}
|
||||
|
||||
@GetMapping(value = "/health/ready", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<String> ready() {
|
||||
String ems = check(portalDataSource);
|
||||
String gw = check(gatewayDataSource);
|
||||
boolean ok = "UP".equals(ems) && "UP".equals(gw);
|
||||
String ems = check(portalDataSource);
|
||||
String gw = check(gatewayDataSource);
|
||||
String view = checkTemplateEngine();
|
||||
String logging = checkJbossLogging();
|
||||
String guava = checkGuava();
|
||||
boolean ok = "UP".equals(ems) && "UP".equals(gw) && "UP".equals(view)
|
||||
&& "UP".equals(logging) && "UP".equals(guava);
|
||||
|
||||
String body = "{"
|
||||
+ "\"status\":\"" + (ok ? "UP" : "DOWN") + "\","
|
||||
+ "\"ems\":\"" + ems + "\","
|
||||
+ "\"gateway\":\""+ gw + "\""
|
||||
+ "\"status\":\"" + (ok ? "UP" : "DOWN") + "\","
|
||||
+ "\"ems\":\"" + ems + "\","
|
||||
+ "\"gateway\":\"" + gw + "\","
|
||||
+ "\"view\":\"" + view + "\","
|
||||
+ "\"logging\":\"" + logging + "\","
|
||||
+ "\"guava\":\"" + guava + "\""
|
||||
+ "}";
|
||||
|
||||
return ResponseEntity.status(ok ? 200 : 503)
|
||||
@@ -55,6 +82,49 @@ public class ReadinessController {
|
||||
.body(body);
|
||||
}
|
||||
|
||||
/** {@code getConfiguration()} lazily runs Thymeleaf's dialect/module init on first call
|
||||
* and caches it — so this is cheap once healthy, and reproduces the exact failure path
|
||||
* a real page render would hit. Catches {@link Throwable}: init failures here have
|
||||
* surfaced as {@link Error} (ServiceConfigurationError), not just Exception. */
|
||||
private String checkTemplateEngine() {
|
||||
try {
|
||||
templateEngine.getConfiguration();
|
||||
return "UP";
|
||||
} catch (Throwable t) {
|
||||
return down(t);
|
||||
}
|
||||
}
|
||||
|
||||
/** Forces jboss-logging's {@code Logger} + its bound provider (LogManager/JBoss/JUL
|
||||
* discovery) to load and actually run a log-level check, not just resolve the class. */
|
||||
private static String checkJbossLogging() {
|
||||
try {
|
||||
Logger log = Logger.getLogger(ReadinessController.class);
|
||||
log.isDebugEnabled();
|
||||
return "UP";
|
||||
} catch (Throwable t) {
|
||||
return down(t);
|
||||
}
|
||||
}
|
||||
|
||||
/** {@code Futures.immediateFuture} touches Guava's {@code util.concurrent} machinery
|
||||
* (AbstractFuture/InternalFutureFailureAccess) — the part backed by the separate
|
||||
* {@code failureaccess} jar, which is exactly where an app/WebLogic version mismatch
|
||||
* would split across classloaders. */
|
||||
private static String checkGuava() {
|
||||
try {
|
||||
Futures.immediateFuture(Boolean.TRUE).isDone();
|
||||
return "UP";
|
||||
} catch (Throwable t) {
|
||||
return down(t);
|
||||
}
|
||||
}
|
||||
|
||||
private static String down(Throwable t) {
|
||||
String msg = t.getMessage() == null ? t.getClass().getSimpleName() : t.getMessage();
|
||||
return "DOWN:" + msg.replace('"', '\'').replace('\n', ' ').replace('\r', ' ');
|
||||
}
|
||||
|
||||
private static String check(DataSource ds) {
|
||||
if (ds == null) return "DOWN:NO_DATASOURCE";
|
||||
try (Connection c = ds.getConnection()) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.apis.controller;
|
||||
|
||||
|
||||
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
|
||||
import com.eactive.apim.portal.apps.apis.service.ApiListProperties;
|
||||
import com.eactive.apim.portal.apps.apis.service.ApiSearchFacade;
|
||||
import com.eactive.apim.portal.apps.apis.service.ApiService;
|
||||
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
|
||||
@@ -17,6 +18,7 @@ import java.util.Optional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.web.PageableDefault;
|
||||
import org.springframework.stereotype.Controller;
|
||||
@@ -37,6 +39,7 @@ public class ApiController {
|
||||
private final ApiServiceService apiServiceService;
|
||||
private final ApiSearchFacade apiSearchFacade;
|
||||
private final ApiStatusCatalogService apiStatusCatalogService;
|
||||
private final ApiListProperties apiListProperties;
|
||||
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
|
||||
private static final String DEFAULT_TOKEN_API_NAME = "인증";
|
||||
|
||||
@@ -88,7 +91,8 @@ public class ApiController {
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<ApiSpecInfoDto> allApis = (List<ApiSpecInfoDto>) searchResult.get("apis");
|
||||
Page<ApiSpecInfoDto> apiPage = slicePage(allApis, pageable);
|
||||
Pageable listPageable = PageRequest.of(pageable.getPageNumber(), apiListProperties.getPageSize(), pageable.getSort());
|
||||
Page<ApiSpecInfoDto> apiPage = slicePage(allApis, listPageable);
|
||||
|
||||
model.addAttribute("search", search);
|
||||
model.addAttribute("services", searchResult.get("services"));
|
||||
@@ -109,8 +113,8 @@ public class ApiController {
|
||||
*/
|
||||
private Page<ApiSpecInfoDto> slicePage(List<ApiSpecInfoDto> apis, Pageable pageable) {
|
||||
int total = apis == null ? 0 : apis.size();
|
||||
int fromIndex = Math.min(pageable.getPageNumber() * pageable.getPageSize(), total);
|
||||
int toIndex = Math.min(fromIndex + pageable.getPageSize(), total);
|
||||
int fromIndex = (int) Math.min(pageable.getOffset(), total);
|
||||
int toIndex = (int) Math.min((long) fromIndex + pageable.getPageSize(), total);
|
||||
List<ApiSpecInfoDto> content = total == 0 ? new ArrayList<>() : apis.subList(fromIndex, toIndex);
|
||||
|
||||
return new PageImpl<>(content, pageable, total);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.eactive.apim.portal.apps.apis.service;
|
||||
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** OPEN API 목록 및 앱/Webhook 신청·수정 API 선택 목록의 공통 페이지 크기. */
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ApiListProperties {
|
||||
|
||||
private static final String PAGE_SIZE_PROPERTY = "api.list.page-size";
|
||||
private static final int DEFAULT_PAGE_SIZE = 15;
|
||||
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
|
||||
/** PTL_PROPERTY(Portal / api.list.page-size)를 조회하고, 없으면 기본값으로 생성한다. */
|
||||
public int getPageSize() {
|
||||
String value = portalPropertyService.getOrCreateProperty(
|
||||
"Portal", PAGE_SIZE_PROPERTY, String.valueOf(DEFAULT_PAGE_SIZE),
|
||||
"API 목록 및 앱/Webhook 신청·수정 API 선택 목록의 페이지당 노출 개수 (양의 정수, 기본 15)");
|
||||
try {
|
||||
int size = Integer.parseInt(value == null ? "" : value.trim());
|
||||
if (size > 0) {
|
||||
return size;
|
||||
}
|
||||
} catch (NumberFormatException ignored) {
|
||||
// 비어 있거나 정수가 아닌 설정은 기본값을 사용한다.
|
||||
}
|
||||
log.warn("{} 값이 올바르지 않음('{}') - 기본값 {} 사용", PAGE_SIZE_PROPERTY, value, DEFAULT_PAGE_SIZE);
|
||||
return DEFAULT_PAGE_SIZE;
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.collections4.CollectionUtils;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.commons.lang3.Strings;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
@@ -155,10 +156,10 @@ public class ApiSpecService {
|
||||
updateReferences(pathItem, apiId);
|
||||
|
||||
String tmpFullPath = StringUtils.isNotEmpty(basePath)
|
||||
? StringUtils.join(basePath, "/", StringUtils.removeStart(path, "/"))
|
||||
? StringUtils.join(basePath, "/", Strings.CS.removeStart(path, "/"))
|
||||
: path;
|
||||
|
||||
final String fullPath = StringUtils.replacePattern(tmpFullPath, "//+", "/");
|
||||
final String fullPath = tmpFullPath.replaceAll("//+", "/");
|
||||
|
||||
// HTTP 메소드별로 중복 체크
|
||||
pathItem.readOperationsMap().forEach((httpMethod, operation) -> {
|
||||
@@ -294,7 +295,7 @@ public class ApiSpecService {
|
||||
return StringUtils.EMPTY;
|
||||
}
|
||||
Server server = api.getServers().get(0);
|
||||
return StringUtils.removeEnd(server.getUrl(), "/");
|
||||
return Strings.CS.removeEnd(server.getUrl(), "/");
|
||||
}
|
||||
|
||||
private void mergeComponents(OpenAPI currentAPI, String apiId, String apiName, Map<String, Object> mergedComponents, ObjectMapper objectMapper) {
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
package com.eactive.apim.portal.apps.app.service;
|
||||
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
/**
|
||||
* 관리자(admin) 포털의 내부 API를 호출하는 클라이언트.
|
||||
*
|
||||
* <p>GW 인증서버(TSEAIAU01) 제어는 포털이 직접 하지 않고, broadcast 인프라를 갖춘 admin 에 위임한다.
|
||||
* admin base URL 은 {@code PTL_PROPERTY} (group={@code Portal}, name={@code djb.admin.base-url}) 에서 조회한다.</p>
|
||||
* admin base URL 은 {@code PTL_PROPERTY} (group={@code Portal}, name={@code admin.base-url}) 에서 조회한다.</p>
|
||||
*
|
||||
* <p><b>다중 base URL / failover</b> — 프로퍼티 값에 콤마({@code ,}) 또는 개행으로 여러 admin 주소를 넣을 수 있다.
|
||||
* 호출은 <b>기재된 순서대로</b> 시도하며, 통신 오류(연결 실패/타임아웃/HTTP 오류)면 다음 주소로 넘어간다.
|
||||
* 모두 실패하면 마지막 오류를 던진다. 반면 admin 이 정상 응답하면서 {@code success=false} 를 준 것은
|
||||
* 업무 처리 실패이므로 <b>failover 하지 않고</b> 즉시 실패시킨다(다른 노드도 같은 결과이며 중복 처리 위험).</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -22,6 +32,8 @@ public class AdminGatewayClient {
|
||||
private static final String PROP_GROUP = "Portal";
|
||||
private static final String PROP_ADMIN_BASE_URL = "admin.base-url";
|
||||
private static final String DEFAULT_ADMIN_BASE_URL = "http://localhost:39120";
|
||||
private static final String PROP_ADMIN_BASE_URL_DESC =
|
||||
"admin(관리자포털) 내부 API base URL. 콤마(,) 또는 줄바꿈으로 여러 개 지정 시 앞에서부터 failover";
|
||||
private static final String CLIENT_BLOCK_PATH = "/onl/admin/authserver/clientBlock.json?clientId={clientId}";
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
@@ -30,26 +42,79 @@ public class AdminGatewayClient {
|
||||
/**
|
||||
* clientId 의 GW 인증 클라이언트 차단(appstatus=0) + GW 캐시 리로드를 admin 에 요청한다.
|
||||
*
|
||||
* <p>base URL 이 여러 개면 앞에서부터 순서대로 시도한다(failover).</p>
|
||||
*
|
||||
* @param clientId 차단할 클라이언트 ID
|
||||
* @throws RuntimeException admin 미응답/네트워크 오류 또는 admin 처리 실패 시 (호출측에서 처리)
|
||||
* @throws RuntimeException admin 미응답/네트워크 오류(전 노드 실패) 또는 admin 처리 실패 시 (호출측에서 처리)
|
||||
*/
|
||||
public void blockClient(String clientId) {
|
||||
String baseUrl = portalPropertyService.getOrCreateProperty(
|
||||
PROP_GROUP, PROP_ADMIN_BASE_URL, DEFAULT_ADMIN_BASE_URL, "admin(관리자포털) 내부 API base URL");
|
||||
List<String> baseUrls = resolveBaseUrls();
|
||||
|
||||
String url = stripTrailingSlashes(baseUrl) + CLIENT_BLOCK_PATH;
|
||||
RestClientException lastError = null;
|
||||
for (int i = 0; i < baseUrls.size(); i++) {
|
||||
String baseUrl = baseUrls.get(i);
|
||||
String url = baseUrl + CLIENT_BLOCK_PATH;
|
||||
|
||||
// 네트워크/HTTP 오류는 RestTemplate 이 예외로 던진다.
|
||||
ResponseEntity<Map> response = restTemplate.postForEntity(url, null, Map.class, clientId);
|
||||
ResponseEntity<Map> response;
|
||||
try {
|
||||
response = restTemplate.postForEntity(url, null, Map.class, clientId);
|
||||
} catch (RestClientException e) {
|
||||
// 통신 계층 실패 — 다음 admin 주소로 failover
|
||||
lastError = e;
|
||||
log.warn("admin clientBlock 호출 실패({}/{}) - baseUrl={}, clientId={}, cause={}",
|
||||
i + 1, baseUrls.size(), baseUrl, clientId, e.toString());
|
||||
continue;
|
||||
}
|
||||
|
||||
Map<?, ?> body = response.getBody();
|
||||
boolean success = body != null && Boolean.TRUE.equals(body.get("success"));
|
||||
if (!success) {
|
||||
String msg = body != null ? String.valueOf(body.get("msg")) : "응답 본문 없음";
|
||||
throw new IllegalStateException("admin clientBlock 처리 실패 - clientId=" + clientId + ", msg=" + msg);
|
||||
// 여기까지 왔으면 admin 이 응답한 것 — 업무 실패는 failover 대상이 아니다.
|
||||
Map<?, ?> body = response.getBody();
|
||||
boolean success = body != null && Boolean.TRUE.equals(body.get("success"));
|
||||
if (!success) {
|
||||
String msg = body != null ? String.valueOf(body.get("msg")) : "응답 본문 없음";
|
||||
throw new IllegalStateException(
|
||||
"admin clientBlock 처리 실패 - baseUrl=" + baseUrl + ", clientId=" + clientId + ", msg=" + msg);
|
||||
}
|
||||
|
||||
log.info("admin GW 차단/리로드 위임 성공 - baseUrl={}, clientId={}", baseUrl, clientId);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("admin GW 차단/리로드 위임 성공 - clientId={}", clientId);
|
||||
throw new IllegalStateException(
|
||||
"admin clientBlock 호출 실패 - 모든 admin 주소 응답 없음 (" + baseUrls + "), clientId=" + clientId, lastError);
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로퍼티의 admin base URL 목록을 순서대로 반환한다.
|
||||
*
|
||||
* <p>구분자는 콤마({@code ,})와 개행({@code \r\n})이며, 공백 항목과 중복은 제거하고 순서는 보존한다.
|
||||
* 값이 비어 있거나 유효 항목이 없으면 기본값 하나만 반환한다.</p>
|
||||
*/
|
||||
private List<String> resolveBaseUrls() {
|
||||
String raw = portalPropertyService.getOrCreateProperty(
|
||||
PROP_GROUP, PROP_ADMIN_BASE_URL, DEFAULT_ADMIN_BASE_URL, PROP_ADMIN_BASE_URL_DESC);
|
||||
|
||||
List<String> urls = parseBaseUrls(raw);
|
||||
if (urls.isEmpty()) {
|
||||
log.warn("admin.base-url 프로퍼티가 비어 있어 기본값 사용 - {}", DEFAULT_ADMIN_BASE_URL);
|
||||
return parseBaseUrls(DEFAULT_ADMIN_BASE_URL);
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
/**
|
||||
* 콤마/개행으로 구분된 base URL 문자열을 정규화된 목록으로 파싱한다.
|
||||
*/
|
||||
static List<String> parseBaseUrls(String raw) {
|
||||
Set<String> ordered = new LinkedHashSet<>();
|
||||
if (raw != null) {
|
||||
for (String token : raw.split("[,\\r\\n]")) {
|
||||
String url = stripTrailingSlashes(token.trim());
|
||||
if (!url.isEmpty()) {
|
||||
ordered.add(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(ordered);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+8
-3
@@ -52,9 +52,10 @@ public class ApiStatisticsController {
|
||||
model.addAttribute("appList", apiStatisticsService.getAppListByOrg(orgId));
|
||||
|
||||
// 기본 조회 (일별, 7일 전 ~ 오늘, 전체 앱)
|
||||
LocalDate today = LocalDate.now();
|
||||
ApiStatisticsSearchDto searchDto = new ApiStatisticsSearchDto();
|
||||
searchDto.setStartDate(LocalDate.now().minusDays(7));
|
||||
searchDto.setEndDate(LocalDate.now());
|
||||
searchDto.setStartDate(today.minusDays(7));
|
||||
searchDto.setEndDate(today);
|
||||
searchDto.setClientId(null);
|
||||
|
||||
ApiStatisticsResultDto result = apiStatisticsService.getStatistics(orgId, searchDto);
|
||||
@@ -62,6 +63,9 @@ public class ApiStatisticsController {
|
||||
model.addAttribute("details", result.getDetails());
|
||||
model.addAttribute("periods", result.getPeriods());
|
||||
model.addAttribute("searchDto", searchDto);
|
||||
// 브라우저 시간대/시계와 관계없이 서버와 동일한 조회 경계를 적용한다.
|
||||
model.addAttribute("statsMinDate", today.minusYears(1).toString());
|
||||
model.addAttribute("statsMaxDate", today.toString());
|
||||
|
||||
// 월별 선택 가능 월 + 집계 안내 문구 데이터
|
||||
model.addAttribute("availableMonths", apiStatisticsService.getAvailableMonths(orgId));
|
||||
@@ -124,7 +128,8 @@ public class ApiStatisticsController {
|
||||
private String rangeErrorMessage(ApiStatisticsSearchDto searchDto) {
|
||||
return searchDto.isMonthly()
|
||||
? "조회할 월이 올바르지 않습니다."
|
||||
: "조회 기간은 최대 " + ApiStatisticsSearchDto.MAX_DATE_RANGE_DAYS + "일까지 가능합니다.";
|
||||
: "조회 기간은 1년 전부터 오늘까지의 날짜 중 최대 " + ApiStatisticsSearchDto.MAX_DATE_RANGE_DAYS
|
||||
+ "일(시작일·종료일 포함)로 선택해주세요.";
|
||||
}
|
||||
|
||||
private PortalOrg getPortalOrg() {
|
||||
|
||||
+9
-4
@@ -15,7 +15,7 @@ import org.springframework.format.annotation.DateTimeFormat;
|
||||
public class ApiStatisticsSearchDto {
|
||||
|
||||
/**
|
||||
* 최대 조회 가능 일수 (일별 모드).
|
||||
* 한 번에 조회 가능한 일수 (일별 모드, 시작일·종료일 포함).
|
||||
*/
|
||||
public static final int MAX_DATE_RANGE_DAYS = 40;
|
||||
|
||||
@@ -59,17 +59,22 @@ public class ApiStatisticsSearchDto {
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜(일별) 범위가 유효한지 검증 (최대 40일).
|
||||
* 1년 전부터 오늘까지의 날짜 중 최대 40일인지 검증 (양 끝 포함).
|
||||
*/
|
||||
public boolean isValidDateRange() {
|
||||
return isValidDateRange(LocalDate.now());
|
||||
}
|
||||
|
||||
boolean isValidDateRange(LocalDate today) {
|
||||
if (startDate == null || endDate == null) {
|
||||
return false;
|
||||
}
|
||||
if (startDate.isAfter(endDate)) {
|
||||
return false;
|
||||
}
|
||||
long daysBetween = ChronoUnit.DAYS.between(startDate, endDate);
|
||||
return daysBetween <= MAX_DATE_RANGE_DAYS;
|
||||
LocalDate earliestDate = today.minusYears(1);
|
||||
return !startDate.isBefore(earliestDate) && !endDate.isAfter(today)
|
||||
&& ChronoUnit.DAYS.between(startDate, endDate) < MAX_DATE_RANGE_DAYS;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,6 @@ import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserService;
|
||||
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
|
||||
import com.eactive.apim.portal.common.util.EncryptionUtil;
|
||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.config.PortalProperties;
|
||||
@@ -22,21 +21,15 @@ import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import javax.crypto.BadPaddingException;
|
||||
import javax.crypto.IllegalBlockSizeException;
|
||||
import javax.crypto.NoSuchPaddingException;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import javax.validation.Valid;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.xerces.impl.dv.util.Base64;
|
||||
import org.springframework.security.access.annotation.Secured;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
@@ -64,7 +57,6 @@ public class UserRegisterController {
|
||||
private final AgreementsFacade agreementsFacade;
|
||||
private final PortalProperties portalProperties;
|
||||
private final UserInvitationRepository userInvitationRepository;
|
||||
private final EncryptionUtil encryptionUtil;
|
||||
private final AgreementValidator agreementValidator;
|
||||
private final PortalUserAuthService portalUserAuthService;
|
||||
|
||||
|
||||
@@ -11,8 +11,6 @@ import com.eactive.apim.portal.apps.user.service.PortalOrgService;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserService;
|
||||
import com.eactive.apim.portal.apps.user.service.UserRegistrationValidationService;
|
||||
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
|
||||
import com.eactive.apim.portal.common.exception.SystemException;
|
||||
import com.eactive.apim.portal.common.util.EncryptionUtil;
|
||||
import com.eactive.apim.portal.file.entity.FileInfo;
|
||||
import com.eactive.apim.portal.file.service.FileService;
|
||||
import com.eactive.apim.portal.file.service.FileTypeContext;
|
||||
@@ -20,26 +18,14 @@ import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||
import com.eactive.apim.portal.template.service.MessageHandlerService;
|
||||
import com.eactive.apim.portal.template.service.MessageRecipient;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.xerces.impl.dv.util.Base64;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import javax.crypto.BadPaddingException;
|
||||
import javax.crypto.IllegalBlockSizeException;
|
||||
import javax.crypto.NoSuchPaddingException;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -58,8 +44,6 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final AgreementValidator agreementValidator;
|
||||
private final ApprovalService approvalService;
|
||||
private final MessageHandlerService messageHandlerService;
|
||||
private final EncryptionUtil encryptionUtil;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
@@ -213,29 +197,10 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
|
||||
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
|
||||
|
||||
approvalService.createUserApproval(newUser);
|
||||
// 11.28 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
|
||||
// sendActivationEmail(newUser);
|
||||
|
||||
return new ValidationResponse(true, "법인 사용자 등록 신청이 완료되었습니다.");
|
||||
}
|
||||
|
||||
private void sendActivationEmail(PortalUser newUser) {
|
||||
MessageRecipient recipient = new MessageRecipient();
|
||||
recipient.setUsername(newUser.getUserName());
|
||||
recipient.setUserId(newUser.getEmailAddr());
|
||||
recipient.setPhone(newUser.getMobileNumber());
|
||||
HashMap<String, Object> params = new HashMap<>();
|
||||
String tokenValue = newUser.getCreatedDate().format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")) + ":" + newUser.getId();
|
||||
|
||||
try {
|
||||
String encToken = encryptionUtil.encrypt(tokenValue);
|
||||
params.put("token", Base64.encode(encToken.getBytes(StandardCharsets.UTF_8)));
|
||||
messageHandlerService.publishEvent(MessageCode.USER_VERIFICATION_EMAIL, recipient, params);
|
||||
} catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
|
||||
throw new SystemException("암호화 모듈 오류");
|
||||
}
|
||||
}
|
||||
|
||||
// 기존 사용자를 법인 사용자로 전환하는 메서드
|
||||
private ValidationResponse convertExistingUserToCorporate(
|
||||
PortalUser existingUser,
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.eactive.apim.portal.apps.user.facade;
|
||||
|
||||
import com.eactive.apim.portal.agreements.entity.AgreementType;
|
||||
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
|
||||
import com.eactive.apim.portal.apps.auth.service.AuthNumberGenerator;
|
||||
import com.eactive.apim.portal.apps.user.dto.PortalUserRegistrationDTO;
|
||||
import com.eactive.apim.portal.apps.user.dto.UserAgreementDTO;
|
||||
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
|
||||
@@ -11,15 +10,11 @@ import com.eactive.apim.portal.apps.user.service.PortalUserService;
|
||||
import com.eactive.apim.portal.apps.user.service.UserRegistrationValidationService;
|
||||
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
|
||||
import com.eactive.apim.portal.apps.user.validator.PasswordValidator;
|
||||
import com.eactive.apim.portal.common.util.EncryptionUtil;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitation;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums;
|
||||
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||
import com.eactive.apim.portal.template.service.MessageHandlerService;
|
||||
import com.eactive.apim.portal.template.service.MessageRecipient;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -31,7 +26,6 @@ import org.springframework.validation.BindingResult;
|
||||
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
@@ -49,9 +43,6 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
private final PasswordValidator passwordValidator;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final AgreementValidator agreementValidator;
|
||||
private final MessageHandlerService messageHandlerService;
|
||||
private final EncryptionUtil encryptionUtil;
|
||||
private final AuthNumberGenerator authNumberGenerator;
|
||||
|
||||
|
||||
@Override
|
||||
@@ -159,8 +150,6 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
}
|
||||
|
||||
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
|
||||
// 11.13 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
|
||||
// sendEmailActivation(newUser);
|
||||
return new ValidationResponse(true,"회원가입이 완료되었습니다.");
|
||||
}
|
||||
|
||||
@@ -229,28 +218,6 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
return false;
|
||||
}
|
||||
|
||||
private void sendEmailActivation(PortalUser registeredUser) {
|
||||
MessageRecipient recipient = new MessageRecipient();
|
||||
recipient.setUsername(registeredUser.getUserName());
|
||||
recipient.setUserId(registeredUser.getEmailAddr());
|
||||
recipient.setPhone(registeredUser.getMobileNumber());
|
||||
|
||||
// 25.10.01 - 이메일 링크 방식으로 접근 불가이기에 SMS 인증방식과 동일하게 대체
|
||||
HashMap<String, Object> params = new HashMap<>();
|
||||
String tokenValue = String.valueOf(authNumberGenerator.generateAuthNumber());
|
||||
// String tokenValue = registeredUser.getCreatedDate().format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")) + ":" + registeredUser.getId();
|
||||
params.put("token", tokenValue);
|
||||
messageHandlerService.publishEvent(MessageCode.USER_VERIFICATION_EMAIL, recipient, params);
|
||||
|
||||
// try {
|
||||
// String encToken = encryptionUtil.encrypt(tokenValue);
|
||||
// params.put("token", Base64.encode(encToken.getBytes(StandardCharsets.UTF_8)));
|
||||
// messageHandlerService.publishEvent(UserEmailActivationEvent.KEY, recipient, params);
|
||||
// } catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
|
||||
// throw new SystemException("암호화 모듈 오류");
|
||||
// }
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public ValidationResponse processInvitation(String action, UserInvitation invitation) {
|
||||
|
||||
@@ -16,23 +16,14 @@ import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||
import com.eactive.apim.portal.portaluser.event.UserPasswordResetEvent;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||
import com.eactive.apim.portal.template.entity.MessageRequest;
|
||||
import com.eactive.apim.portal.template.repository.MessageRequestRepository;
|
||||
import com.eactive.apim.portal.template.service.MessageHandlerService;
|
||||
import com.eactive.apim.portal.template.service.MessageRecipient;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.crypto.BadPaddingException;
|
||||
import javax.crypto.IllegalBlockSizeException;
|
||||
import javax.crypto.NoSuchPaddingException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.xerces.impl.dv.util.Base64;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
@@ -54,7 +45,6 @@ public class PortalUserAuthService implements UserDetailsService {
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final MessageHandlerService messageHandlerService;
|
||||
private final MessageRequestRepository messageRequestRepository;
|
||||
private final EncryptionUtil encryptionUtil;
|
||||
private final LoginFinalizer loginFinalizer;
|
||||
private final PasswordService passwordService;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.eactive.apim.portal.common.security.PasswordPolicyProperties;
|
||||
import com.eactive.apim.portal.djb.footer.RelatedSite;
|
||||
import com.eactive.apim.portal.djb.footer.RelatedSiteService;
|
||||
import com.eactive.apim.portal.djb.guide.GuideProperty;
|
||||
import com.eactive.apim.portal.common.security.passwordcrypto.PasswordCryptoProperties;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
|
||||
@ControllerAdvice
|
||||
@@ -51,6 +52,9 @@ public class GlobalControllerAdvice {
|
||||
@Autowired
|
||||
private Environment environment;
|
||||
|
||||
@Autowired
|
||||
private PasswordCryptoProperties passwordCryptoProperties;
|
||||
|
||||
@ModelAttribute("breadcrumb")
|
||||
public List<Map> addBreadcrumbToModel(HttpServletRequest request) {
|
||||
String currentPath = request.getRequestURI();
|
||||
@@ -125,6 +129,25 @@ public class GlobalControllerAdvice {
|
||||
return passwordPolicyProperties.isKeyboardSequenceBlocked();
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 전송암호화 설정(yml {@code portal.security.password-encrypt.*}).
|
||||
* head 의 {@code window.__PASSWORD_CRYPTO__} 로 내려가 password-crypto.js 가 읽는다.
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code enabled} - 꺼져 있으면 화면은 아무것도 하지 않고 평문 전송한다.</li>
|
||||
* <li>{@code policy} - 평문 비밀번호 처리 정책. 화면은 경고 팝업 노출 여부·문구를 여기서 정한다.</li>
|
||||
* <li>{@code softwareFallback} - {@code crypto.subtle} 이 없을 때 forge 번들을 내려받아 쓸지.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@ModelAttribute("passwordCrypto")
|
||||
public Map<String, Object> passwordCrypto() {
|
||||
Map<String, Object> config = new java.util.LinkedHashMap<>();
|
||||
config.put("enabled", passwordCryptoProperties.isEnabled());
|
||||
config.put("policy", passwordCryptoProperties.getPlaintextPolicy().name());
|
||||
config.put("softwareFallback", passwordCryptoProperties.isSoftwareFallback());
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상단 헤더 좌측 노출용 활성 프로파일 배지. prod 프로파일이면 노출하지 않는다(null).
|
||||
*/
|
||||
|
||||
+47
-14
@@ -1,8 +1,7 @@
|
||||
package com.eactive.apim.portal.common.exception;
|
||||
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.regex.Pattern;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
|
||||
import com.eactive.apim.portal.apps.login.service.LoginFinalizer;
|
||||
@@ -36,23 +35,27 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
@RequiredArgsConstructor
|
||||
public class PortalGlobalExceptionHandler {
|
||||
|
||||
private static final String REDIRECT_PREFIX = "redirect:";
|
||||
private static final String REDIRECT_HOME = REDIRECT_PREFIX + "/";
|
||||
private static final String REDIRECT_LOGIN_AUTH = REDIRECT_PREFIX + "/login?reason=auth";
|
||||
|
||||
private final Logger log = LoggerFactory.getLogger(getClass());
|
||||
private final PortalProperties portalProperties;
|
||||
private final Environment environment;
|
||||
|
||||
@ExceptionHandler(value = NotFoundException.class)
|
||||
public ModelAndView handleINotFoundException(HttpServletRequest request, NotFoundException ex) {
|
||||
return new ModelAndView("redirect:/");
|
||||
return new ModelAndView(REDIRECT_HOME);
|
||||
}
|
||||
|
||||
@ExceptionHandler(value = MethodArgumentTypeMismatchException.class)
|
||||
public ModelAndView handleMethodArgumentTypeMismatchException(HttpServletRequest request, MethodArgumentTypeMismatchException ex) {
|
||||
return new ModelAndView("redirect:/");
|
||||
return new ModelAndView(REDIRECT_HOME);
|
||||
}
|
||||
|
||||
@ExceptionHandler(value = UserNotLoginException.class)
|
||||
public ModelAndView handleUserNotLoginException(HttpServletRequest request, UserNotLoginException ex) {
|
||||
return new ModelAndView("redirect:/login?reason=auth");
|
||||
return new ModelAndView(REDIRECT_LOGIN_AUTH);
|
||||
}
|
||||
|
||||
@ExceptionHandler(value = AccessDeniedException.class)
|
||||
@@ -61,7 +64,7 @@ public class PortalGlobalExceptionHandler {
|
||||
if (!SecurityUtil.isAuthenticated()) {
|
||||
// 원래 요청 페이지를 세션에 저장 → 로그인+2FA 완료 후 LoginFinalizer 가 복귀시킨다.
|
||||
savePostLoginRedirect(request);
|
||||
return new ModelAndView("redirect:/login?reason=auth");
|
||||
return new ModelAndView(REDIRECT_LOGIN_AUTH);
|
||||
}
|
||||
log.warn("접근 권한 없음: loginId={}, uri={}", StringMaskingUtil.maskLoginId(SecurityUtil.getCurrentLoginId()), request.getRequestURI());
|
||||
ModelAndView modelAndView = new ModelAndView("error");
|
||||
@@ -122,9 +125,40 @@ public class PortalGlobalExceptionHandler {
|
||||
|| path.startsWith("/favicon"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 리다이렉트/뷰 이름으로 허용할 문자. 스킴 구분자(:)·중괄호·달러를 막아
|
||||
* 외부 URL 이나 Thymeleaf 표현식이 뷰 이름으로 흘러드는 것을 차단한다.
|
||||
*/
|
||||
private static final Pattern SAFE_REDIRECT_TARGET = Pattern.compile("[A-Za-z0-9._/?=&%-]+");
|
||||
|
||||
@ExceptionHandler(value = PortalRedirectException.class)
|
||||
public ModelAndView handlePortalRedirectException(HttpServletRequest request, PortalRedirectException ex) {
|
||||
return new ModelAndView(ex.getMessage());
|
||||
// PortalRedirectException 은 super(message) 를 호출하지 않으므로 getMessage() 는 항상 null 이다.
|
||||
// 이전 구현은 그 null 을 뷰 이름으로 넘겨(= 뷰 미지정) 요청 URL 기준으로 뷰가 추론되게 만들었다.
|
||||
// 실제 대상은 redirectPage 필드다.
|
||||
String target = ex.getRedirectPage();
|
||||
if (!isSafeRedirectTarget(target)) {
|
||||
log.warn("허용되지 않은 리다이렉트 대상 - uri={}, target={}", request.getRequestURI(), target);
|
||||
return new ModelAndView(REDIRECT_HOME);
|
||||
}
|
||||
return new ModelAndView(target);
|
||||
}
|
||||
|
||||
/** 내부 경로/뷰 이름만 허용(외부 URL·프로토콜 상대 URL·표현식 문자 차단) */
|
||||
private boolean isSafeRedirectTarget(String target) {
|
||||
if (target == null) {
|
||||
return false;
|
||||
}
|
||||
String path = target;
|
||||
if (path.startsWith(REDIRECT_PREFIX)) {
|
||||
path = path.substring(REDIRECT_PREFIX.length());
|
||||
} else if (path.startsWith("forward:")) {
|
||||
path = path.substring("forward:".length());
|
||||
}
|
||||
if (path.isEmpty() || path.startsWith("//")) { // //evil.example 형태의 외부 리다이렉트 차단
|
||||
return false;
|
||||
}
|
||||
return SAFE_REDIRECT_TARGET.matcher(path).matches();
|
||||
}
|
||||
|
||||
@ExceptionHandler(value = {IllegalArgumentException.class})
|
||||
@@ -140,7 +174,7 @@ public class PortalGlobalExceptionHandler {
|
||||
public ModelAndView handleHttpRequestMethodNotSupportedException(HttpServletRequest request, HttpRequestMethodNotSupportedException ex) {
|
||||
log.error(ex.getMessage());
|
||||
ModelAndView modelAndView = new ModelAndView();
|
||||
modelAndView.setViewName("redirect:/");
|
||||
modelAndView.setViewName(REDIRECT_HOME);
|
||||
return modelAndView;
|
||||
}
|
||||
|
||||
@@ -159,10 +193,9 @@ public class PortalGlobalExceptionHandler {
|
||||
*/
|
||||
@ExceptionHandler(value = Exception.class)
|
||||
public ModelAndView handleException(HttpServletRequest request, Exception ex) {
|
||||
String requestParams = request.getParameterMap().entrySet()
|
||||
.stream()
|
||||
.map(entry -> entry.getKey() + "=" + Arrays.toString(entry.getValue()))
|
||||
.collect(Collectors.joining(", "));
|
||||
// 비밀번호·시크릿·토큰 계열 파라미터는 값을 가린다. 전송암호화가 켜져 있어도
|
||||
// 이 시점의 파라미터는 이미 복호화된 평문이다.
|
||||
String requestParams = StringMaskingUtil.maskParameterMap(request.getParameterMap());
|
||||
|
||||
log.error("Exception occurred - url={}, params={}", request.getRequestURL(), requestParams, ex);
|
||||
|
||||
@@ -184,7 +217,7 @@ public class PortalGlobalExceptionHandler {
|
||||
public ModelAndView handleInvalidFileException(HttpServletRequest request, RedirectAttributes redirectAttributes, InvalidFileException ex) {
|
||||
ModelAndView modelAndView = new ModelAndView();
|
||||
redirectAttributes.addFlashAttribute("error", ex.getMessage());
|
||||
modelAndView.setViewName("redirect:" + request.getRequestURI());
|
||||
modelAndView.setViewName(REDIRECT_PREFIX + request.getRequestURI());
|
||||
return modelAndView;
|
||||
}
|
||||
|
||||
@@ -195,7 +228,7 @@ public class PortalGlobalExceptionHandler {
|
||||
String errorMessage = "파일 크기가 허용된 최대 용량(" + maxSize + ")을 초과했습니다.";
|
||||
redirectAttributes.addFlashAttribute("error", errorMessage);
|
||||
ModelAndView modelAndView = new ModelAndView();
|
||||
modelAndView.setViewName("redirect:" + request.getRequestURI());
|
||||
modelAndView.setViewName(REDIRECT_PREFIX + request.getRequestURI());
|
||||
return modelAndView;
|
||||
}
|
||||
}
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package com.eactive.apim.portal.common.security.passwordcrypto;
|
||||
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.multipart.MultipartHttpServletRequest;
|
||||
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 멀티파트(파일 업로드 동반) 요청 전용 복호화 래퍼.
|
||||
*
|
||||
* <p><b>배경</b> — {@link DecryptingRequestWrapper} 는 {@code getParameter()} 호출 시
|
||||
* {@code super.getParameter()} 위임 결과를 복호화하는 방식이다. 그런데 Spring 의
|
||||
* {@link org.springframework.web.multipart.support.MultipartFilter} 가 만드는
|
||||
* {@code MultipartHttpServletRequest} 는 자신이 직접 파싱한 폼 필드(파일이 아닌 text part) 값을
|
||||
* {@code getParameter()} 에서 곧바로 반환하고 {@code super.getParameter()} 로 위임하지 않는다.
|
||||
* 그 결과 {@code DecryptingRequestWrapper} 가 멀티파트 파싱 "이전"(안쪽)에 씌워지면, 멀티파트 안의
|
||||
* 비밀번호 파라미터는 복호화되지 않은 봉투(ENC1) 문자열 그대로 컨트롤러까지 전달된다 — 법인가입처럼
|
||||
* 파일 첨부(사업자등록증)와 비밀번호가 같은 폼에 있는 경우 재현된다.
|
||||
*
|
||||
* <p><b>해결</b> — {@link PasswordDecryptFilter} 를 MultipartFilter "이후"에 실행되도록 순서를
|
||||
* 옮기면(그리고 Lucy XSS 필터보다는 여전히 앞서도록), 이 필터가 감싸는 대상이 이미
|
||||
* {@code MultipartHttpServletRequest} 다. 이때는 이 클래스로 감싸 {@link MultipartHttpServletRequest}
|
||||
* 인터페이스 자체를 구현하고 파일 관련 메서드는 원본 멀티파트 요청에 위임한다 — Spring 의
|
||||
* {@code MultipartFile} 바인딩({@code WebUtils.getNativeRequest(request, MultipartHttpServletRequest.class)})이
|
||||
* 이 래퍼를 그대로 인식하도록 하기 위함이다. 파라미터(텍스트 필드) 복호화 로직은 부모
|
||||
* {@link DecryptingRequestWrapper} 그대로 재사용한다.
|
||||
*/
|
||||
public class DecryptingMultipartRequestWrapper extends DecryptingRequestWrapper
|
||||
implements MultipartHttpServletRequest {
|
||||
|
||||
private final MultipartHttpServletRequest multipartRequest;
|
||||
|
||||
public DecryptingMultipartRequestWrapper(MultipartHttpServletRequest request,
|
||||
PasswordEnvelopeCodec codec,
|
||||
PasswordKeyStore keyStore,
|
||||
PasswordCryptoProperties properties) {
|
||||
super(request, codec, keyStore, properties);
|
||||
this.multipartRequest = request;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Iterator<String> getFileNames() {
|
||||
return multipartRequest.getFileNames();
|
||||
}
|
||||
|
||||
@Override
|
||||
public MultipartFile getFile(String name) {
|
||||
return multipartRequest.getFile(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<MultipartFile> getFiles(String name) {
|
||||
return multipartRequest.getFiles(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, MultipartFile> getFileMap() {
|
||||
return multipartRequest.getFileMap();
|
||||
}
|
||||
|
||||
@Override
|
||||
public MultiValueMap<String, MultipartFile> getMultiFileMap() {
|
||||
return multipartRequest.getMultiFileMap();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getMultipartContentType(String paramOrFileName) {
|
||||
return multipartRequest.getMultipartContentType(paramOrFileName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public HttpMethod getRequestMethod() {
|
||||
return multipartRequest.getRequestMethod();
|
||||
}
|
||||
|
||||
@Override
|
||||
public HttpHeaders getRequestHeaders() {
|
||||
return multipartRequest.getRequestHeaders();
|
||||
}
|
||||
|
||||
@Override
|
||||
public HttpHeaders getMultipartHeaders(String paramOrFileName) {
|
||||
return multipartRequest.getMultipartHeaders(paramOrFileName);
|
||||
}
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
package com.eactive.apim.portal.common.security.passwordcrypto;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletRequestWrapper;
|
||||
import java.security.PrivateKey;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 봉투(ENC1) 형식으로 들어온 파라미터 값을 평문으로 되돌리는 요청 래퍼.
|
||||
*
|
||||
* <p><b>지연 복호화가 필수다.</b> 생성자에서 {@code getParameterMap()} 을 부르면 컨테이너가 요청 본문을
|
||||
* 파싱해버려, 본문을 직접 읽는 필터({@code ApiTesterFilter})가 이후 {@code getInputStream()} 을 못 쓴다.
|
||||
* 따라서 값은 {@code getParameter*} 호출 시점에만 건드린다.</p>
|
||||
*
|
||||
* <p>Lucy XSS 필터({@code order = MIN_VALUE + 3})보다 <b>앞</b>({@code MIN_VALUE + 2})에서 이 래퍼가
|
||||
* 씌워지므로, 복호화된 평문이 기존과 똑같이 XSS 이스케이프를 거친다. 순서가 뒤바뀌면 특수문자가 든
|
||||
* 비밀번호의 이스케이프 여부가 달라져 기존 계정 로그인이 깨진다.</p>
|
||||
*
|
||||
* <p><b>멀티파트(파일 업로드) 요청 주의</b> — {@code getParameter()} 는 {@code super.getParameter()} 위임
|
||||
* 결과를 복호화하는 방식이라, 이 래퍼가 감싸는 시점에 {@code super} 가 이미 멀티파트 파싱이 끝난
|
||||
* {@code MultipartHttpServletRequest} 여야만 폼 필드(파일이 아닌 text part)를 볼 수 있다 — Spring 의
|
||||
* 멀티파트 파라미터 맵은 자신이 파싱한 값을 곧바로 반환하고 감싸고 있는 원본 요청으로 위임하지 않기
|
||||
* 때문이다. 그래서 {@link PasswordDecryptFilter} 는 MultipartFilter({@code order = MIN_VALUE + 1})
|
||||
* "이후"에 실행되도록 등록돼 있고, 감싸는 대상이 {@code MultipartHttpServletRequest} 면 이 클래스 대신
|
||||
* {@link DecryptingMultipartRequestWrapper}(이 클래스를 상속하며 멀티파트 인터페이스도 구현)를 쓴다.</p>
|
||||
*/
|
||||
@Slf4j
|
||||
public class DecryptingRequestWrapper extends HttpServletRequestWrapper {
|
||||
|
||||
private final PasswordEnvelopeCodec codec;
|
||||
private final PasswordKeyStore keyStore;
|
||||
private final PasswordCryptoProperties properties;
|
||||
|
||||
/** 봉투 원문 → 평문. 같은 값이 여러 번 조회돼도 RSA 연산은 한 번만 한다. */
|
||||
private final Map<String, String> decrypted = new HashMap<>();
|
||||
|
||||
/** 요청 처리 후 폐기할 keyId(1회용 키 모드). */
|
||||
private final Set<String> usedKeyIds = new LinkedHashSet<>();
|
||||
|
||||
private Map<String, String[]> parameterMapCache;
|
||||
|
||||
/**
|
||||
* 평문 비밀번호 경고를 요청당 한 번만 남기기 위한 표시.
|
||||
* 파라미터는 컨트롤러·검증기에서 여러 번 조회되고, 같은 폼에 비밀번호 계열 필드가 둘 이상인
|
||||
* 경우도 흔해서(비밀번호/비밀번호확인) 그대로 두면 한 번의 제출이 로그 여러 줄을 만든다.
|
||||
*/
|
||||
private boolean plaintextWarned;
|
||||
|
||||
public DecryptingRequestWrapper(HttpServletRequest request,
|
||||
PasswordEnvelopeCodec codec,
|
||||
PasswordKeyStore keyStore,
|
||||
PasswordCryptoProperties properties) {
|
||||
super(request);
|
||||
this.codec = codec;
|
||||
this.keyStore = keyStore;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getParameter(String name) {
|
||||
return convert(name, super.getParameter(name));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String[] getParameterValues(String name) {
|
||||
String[] values = super.getParameterValues(name);
|
||||
if (values == null) {
|
||||
return null;
|
||||
}
|
||||
String[] converted = new String[values.length];
|
||||
for (int i = 0; i < values.length; i++) {
|
||||
converted[i] = convert(name, values[i]);
|
||||
}
|
||||
return converted;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, String[]> getParameterMap() {
|
||||
if (parameterMapCache == null) {
|
||||
Map<String, String[]> source = super.getParameterMap();
|
||||
Map<String, String[]> result = new HashMap<>(Math.max(16, source.size() * 2));
|
||||
for (Map.Entry<String, String[]> entry : source.entrySet()) {
|
||||
String name = entry.getKey();
|
||||
String[] values = entry.getValue();
|
||||
String[] converted = new String[values.length];
|
||||
for (int i = 0; i < values.length; i++) {
|
||||
converted[i] = convert(name, values[i]);
|
||||
}
|
||||
result.put(name, converted);
|
||||
}
|
||||
parameterMapCache = Collections.unmodifiableMap(result);
|
||||
}
|
||||
return parameterMapCache;
|
||||
}
|
||||
|
||||
/** 요청 처리가 끝난 뒤 필터가 호출한다. 1회용 키를 폐기해 재전송을 막는다. */
|
||||
void consumeUsedKeys() {
|
||||
for (String keyId : usedKeyIds) {
|
||||
keyStore.consume(keyId, (HttpServletRequest) getRequest());
|
||||
}
|
||||
usedKeyIds.clear();
|
||||
}
|
||||
|
||||
private String convert(String name, String value) {
|
||||
if (value == null || value.isEmpty()) {
|
||||
return value;
|
||||
}
|
||||
if (!codec.isEnvelope(value)) {
|
||||
return handlePlaintext(name, value);
|
||||
}
|
||||
|
||||
String cached = decrypted.get(value);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
String keyId = codec.keyIdOf(value);
|
||||
if (keyId == null) {
|
||||
log.warn("봉투 형식 오류 - uri={}, param={}", getRequestURI(), name);
|
||||
return failed(value);
|
||||
}
|
||||
|
||||
PrivateKey privateKey = keyStore.resolve(keyId, (HttpServletRequest) getRequest());
|
||||
if (privateKey == null) {
|
||||
log.warn("전송암호화 키를 찾을 수 없음(만료·인스턴스 불일치) - uri={}, param={}, keyId={}",
|
||||
getRequestURI(), name, keyId);
|
||||
return failed(value);
|
||||
}
|
||||
|
||||
try {
|
||||
String plain = codec.decrypt(value, privateKey);
|
||||
decrypted.put(value, plain);
|
||||
usedKeyIds.add(keyId);
|
||||
return plain;
|
||||
} catch (PasswordDecryptException e) {
|
||||
log.warn("전송암호화 복호화 실패 - uri={}, param={}, keyId={}, reason={}",
|
||||
getRequestURI(), name, keyId, e.getMessage());
|
||||
usedKeyIds.add(keyId);
|
||||
return failed(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 봉투가 씌워지지 않은 파라미터 처리. 비밀번호 계열이 아니면 그대로 통과시킨다.
|
||||
*
|
||||
* <p>{@code ENFORCE} 에서 예외를 던지면 로그인 경로가 500 이 되므로 빈 값으로 바꿔
|
||||
* 기존 인증 실패 흐름(아이디/비밀번호 확인)을 타게 한다.</p>
|
||||
*/
|
||||
private String handlePlaintext(String name, String value) {
|
||||
PasswordCryptoProperties.PlaintextPolicy policy = properties.getPlaintextPolicy();
|
||||
if (policy == PasswordCryptoProperties.PlaintextPolicy.NONE
|
||||
|| !PasswordParamNames.isPasswordLike(name)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
boolean reject = policy == PasswordCryptoProperties.PlaintextPolicy.ENFORCE;
|
||||
if (!plaintextWarned) {
|
||||
plaintextWarned = true;
|
||||
log.warn("암호화되지 않은 비밀번호 파라미터 {} - policy={}, transport={}, uri={}, param={}",
|
||||
reject ? "거부" : "허용", policy,
|
||||
RequestTransport.isSecure((HttpServletRequest) getRequest()) ? "https" : "http",
|
||||
getRequestURI(), name);
|
||||
}
|
||||
return reject ? "" : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 복호화 실패 시의 값. 봉투 문자열을 그대로 흘려보내면 비밀번호 정책 검증 등이 엉뚱하게 통과할 수 있어
|
||||
* 빈 값으로 바꾼다. 결과적으로 사용자에게는 일반적인 입력값 오류로 보인다.
|
||||
*/
|
||||
private String failed(String rawValue) {
|
||||
decrypted.put(rawValue, "");
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.eactive.apim.portal.common.security.passwordcrypto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 클라이언트에 내려줄 공개키 1건. {@code PasswordKeyController} 응답 본문이기도 하다.
|
||||
*/
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
public class IssuedKey {
|
||||
|
||||
/** 봉투(ENC1)의 두 번째 세그먼트로 되돌아오는 키 식별자. */
|
||||
private final String keyId;
|
||||
|
||||
/** X.509 SubjectPublicKeyInfo(SPKI) DER 을 표준 base64 로 인코딩한 값. */
|
||||
private final String publicKey;
|
||||
|
||||
/** 남은 수명(초). 클라이언트 캐시 판단용. */
|
||||
private final int expiresIn;
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package com.eactive.apim.portal.common.security.passwordcrypto;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 비밀번호 전송암호화 설정. {@code portal.security.password-encrypt.*}
|
||||
*
|
||||
* <p>브라우저 개발자도구 Network 탭에 비밀번호가 평문으로 보이는 것을 막기 위한 기능이다.</p>
|
||||
*
|
||||
* <p><b>이 기능은 XSS 방어도, MITM 방어도 아니다.</b> 스크립트가 주입되면 입력창에서 직접 탈취할 수 있고,
|
||||
* HTTP 구간이면 중간자가 이 스크립트 자체를 바꿔치기할 수 있다. "전송 페이로드 평문 노출" 점검 지적에
|
||||
* 대한 대응 범위로만 이해할 것.</p>
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "portal.security.password-encrypt")
|
||||
public class PasswordCryptoProperties {
|
||||
|
||||
/**
|
||||
* RSA 개인키 보관 범위.
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code REQUEST} - 폼 진입마다 1회용 키를 발급하고 요청 1회 사용 후 폐기. 재전송 공격 차단이 가장 강하다.</li>
|
||||
* <li>{@code SESSION} - 세션 단위로 키를 보관. 세션 복제 환경에 유리하다.</li>
|
||||
* <li>{@code SERVER} - 서버 고정 키쌍 + TTL 로테이션. 무상태라 가장 단순하지만 재전송 방지 수단이 없다.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public enum KeyScope {
|
||||
REQUEST, SESSION, SERVER
|
||||
}
|
||||
|
||||
/**
|
||||
* 봉투(ENC1)가 적용되지 않은 <b>평문 비밀번호 파라미터</b>를 서버가 어떻게 다룰지.
|
||||
* 전송 구간(HTTP/HTTPS) 자체를 막는 스위치가 아니다 — 전송 구간은 경고 문구에만 영향을 준다.
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code NONE} - 무동작. 평문을 그대로 받는다.</li>
|
||||
* <li>{@code PERMISSIVE} - 평문을 받되 서버 로그에 경고를 남기고, 화면에도 경고 팝업을 띄운다.</li>
|
||||
* <li>{@code ENFORCE} - 평문 비밀번호를 거부한다(빈 값 치환 → 인증 실패).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>{@code ENFORCE} 는 전송 구간이 HTTP 여도 안전하게 켤 수 있다. {@link #softwareFallback} 이 켜져 있으면
|
||||
* {@code crypto.subtle} 을 못 쓰는 환경에서도 클라이언트가 봉투를 만들기 때문이다. 다만 JS 를 끈
|
||||
* 브라우저는 로그인하지 못하므로 {@code PERMISSIVE} 로 운영해 경고 로그를 지켜본 뒤 승격한다.</p>
|
||||
*/
|
||||
public enum PlaintextPolicy {
|
||||
NONE, PERMISSIVE, ENFORCE
|
||||
}
|
||||
|
||||
/** 마스터 스위치. 꺼져 있으면 필터·엔드포인트가 모두 무동작이고 화면은 평문 전송한다. */
|
||||
private boolean enabled = false;
|
||||
|
||||
/** 키 보관 범위. */
|
||||
private KeyScope keyScope = KeyScope.REQUEST;
|
||||
|
||||
/** 발급된 키의 수명(초). REQUEST/SESSION 은 만료 기준, SERVER 는 로테이션 주기. */
|
||||
private int keyTtlSeconds = 300;
|
||||
|
||||
/** 평문 비밀번호 파라미터 처리 정책. */
|
||||
private PlaintextPolicy plaintextPolicy = PlaintextPolicy.NONE;
|
||||
|
||||
/**
|
||||
* {@code crypto.subtle} 을 쓸 수 없는 환경(원격 오리진 HTTP = 비 secure context)에서
|
||||
* 순수 JS 구현(forge)으로 봉투를 만들지 여부.
|
||||
*
|
||||
* <p>forge 번들은 {@code crypto.subtle} 이 없을 때만 동적으로 내려가므로 HTTPS 운영 환경에서는
|
||||
* 전송 바이트가 0 이다. 즉 이 값은 사실상 킬스위치다. 끄면 HTTP 개발환경은 평문으로 폴백한다
|
||||
* ({@link #plaintextPolicy} 가 {@code ENFORCE} 면 로그인 불가).</p>
|
||||
*/
|
||||
private boolean softwareFallback = true;
|
||||
|
||||
/** RSA 키 길이. */
|
||||
private int rsaKeySize = 2048;
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.eactive.apim.portal.common.security.passwordcrypto;
|
||||
|
||||
/**
|
||||
* 봉투 복호화 실패. 화면으로 스택을 올리지 않고 필터에서 흡수한다.
|
||||
* 메시지에 평문이나 키 자료를 담지 않는다.
|
||||
*/
|
||||
public class PasswordDecryptException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public PasswordDecryptException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public PasswordDecryptException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package com.eactive.apim.portal.common.security.passwordcrypto;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.multipart.MultipartHttpServletRequest;
|
||||
|
||||
import javax.servlet.Filter;
|
||||
import javax.servlet.FilterChain;
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.ServletRequest;
|
||||
import javax.servlet.ServletResponse;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 봉투(ENC1) 파라미터를 평문으로 되돌리는 서블릿 필터.
|
||||
*
|
||||
* <p>{@code PortalConfigSecurity} 에서 order {@code Integer.MIN_VALUE + 2} 로 등록해
|
||||
* MultipartFilter({@code MIN_VALUE + 1}) "이후", Lucy XSS 필터({@code MIN_VALUE + 3}) "이전"에
|
||||
* 실행된다. 자세한 이유는 {@link DecryptingRequestWrapper} / {@link DecryptingMultipartRequestWrapper}
|
||||
* 주석 참고 — 멀티파트(파일 업로드 동반) 요청은 MultipartFilter 가 만든
|
||||
* {@code MultipartHttpServletRequest} 가 폼 필드를 자체 파싱해 반환하므로, 이 필터가 그보다
|
||||
* 먼저 실행되면 비밀번호 파라미터가 복호화되지 않은 채로 컨트롤러까지 전달된다(법인가입처럼
|
||||
* 파일 첨부와 비밀번호가 같은 폼에 있는 경우 재현).</p>
|
||||
*
|
||||
* <p>기능이 꺼져 있거나 POST 가 아니면 아무것도 하지 않는다. 래퍼는 파라미터를 조회할 때만
|
||||
* 복호화하므로, 봉투가 없는 요청에는 사실상 비용이 없다.</p>
|
||||
*
|
||||
* <p><b>{@code /internal/**} 은 대상에서 제외한다.</b> playwright 전용 테스트 정리 API
|
||||
* ({@code TestCleanupInternalController} 등)는 공유 토큰({@code X-Internal-Token}) + IP 허용목록으로
|
||||
* 이미 인증되는 서버-to-서버 호출이라 브라우저 암호화 대상이 아니다({@code MenuAccessInterceptor} 가
|
||||
* 같은 이유로 {@code /internal/**} 을 제외하는 것과 동일 패턴). 이 경로를 필터에 그대로 태우면, 도구가
|
||||
* 보내는 평문 {@code password} 파라미터가 {@code plaintext-policy=ENFORCE} 설정에 걸려 빈 문자열로
|
||||
* 치환되고 내부 API 는 "필수값 누락"으로 거부한다(예: {@code /internal/test-cleanup/password}).</p>
|
||||
*/
|
||||
@Component
|
||||
public class PasswordDecryptFilter implements Filter {
|
||||
|
||||
private static final String INTERNAL_API_PREFIX = "/internal/";
|
||||
|
||||
private final PasswordCryptoProperties properties;
|
||||
private final PasswordEnvelopeCodec codec;
|
||||
private final PasswordKeyStore keyStore;
|
||||
|
||||
public PasswordDecryptFilter(PasswordCryptoProperties properties,
|
||||
PasswordEnvelopeCodec codec,
|
||||
PasswordKeyStore keyStore) {
|
||||
this.properties = properties;
|
||||
this.codec = codec;
|
||||
this.keyStore = keyStore;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
|
||||
throws IOException, ServletException {
|
||||
|
||||
if (!properties.isEnabled() || !(request instanceof HttpServletRequest)) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
HttpServletRequest httpRequest = (HttpServletRequest) request;
|
||||
if (!"POST".equalsIgnoreCase(httpRequest.getMethod()) || isInternalApiRequest(httpRequest)) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
DecryptingRequestWrapper wrapper = httpRequest instanceof MultipartHttpServletRequest
|
||||
? new DecryptingMultipartRequestWrapper((MultipartHttpServletRequest) httpRequest, codec, keyStore, properties)
|
||||
: new DecryptingRequestWrapper(httpRequest, codec, keyStore, properties);
|
||||
try {
|
||||
chain.doFilter(wrapper, response);
|
||||
} finally {
|
||||
wrapper.consumeUsedKeys();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isInternalApiRequest(HttpServletRequest request) {
|
||||
String path = request.getRequestURI().substring(request.getContextPath().length());
|
||||
return path.startsWith(INTERNAL_API_PREFIX);
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
package com.eactive.apim.portal.common.security.passwordcrypto;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.OAEPParameterSpec;
|
||||
import javax.crypto.spec.PSource;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.Key;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.spec.MGF1ParameterSpec;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* 클라이언트가 만든 봉투(envelope) 문자열의 파싱·복호화.
|
||||
*
|
||||
* <pre>
|
||||
* ENC1.<keyId>.<b64url(RSA-OAEP(AES키))>.<b64url(iv 12B)>.<b64url(AES-GCM 암호문+태그)>
|
||||
* </pre>
|
||||
*
|
||||
* <p>세그먼트는 base64<b>url</b>(패딩 없음)이라 Lucy XSS 이스케이프와 urlencode 를 모두 통과해도
|
||||
* 값이 변형되지 않는다.</p>
|
||||
*
|
||||
* <p>RSA-OAEP 는 반드시 MGF1 해시까지 SHA-256 으로 지정해야 한다. SunJCE 는
|
||||
* {@code OAEPWithSHA-256AndMGF1Padding} 만 지정하면 MGF1 에 SHA-1 을 쓰는데,
|
||||
* 브라우저 Web Crypto 의 {@code RSA-OAEP + SHA-256} 은 MGF1 도 SHA-256 이라 그대로 두면
|
||||
* 복호화가 실패한다.</p>
|
||||
*/
|
||||
@Component
|
||||
public class PasswordEnvelopeCodec {
|
||||
|
||||
public static final String PREFIX = "ENC1.";
|
||||
|
||||
/** AES-GCM 인증 태그 길이(비트). Web Crypto 기본값과 동일. */
|
||||
private static final int GCM_TAG_BITS = 128;
|
||||
|
||||
private static final Base64.Decoder URL_DECODER = Base64.getUrlDecoder();
|
||||
|
||||
/** 값이 봉투 형식인지. 파싱 비용 없이 접두사만 본다. */
|
||||
public boolean isEnvelope(String value) {
|
||||
return value != null && value.startsWith(PREFIX);
|
||||
}
|
||||
|
||||
/** 봉투에서 keyId 만 추출. 형식이 어긋나면 {@code null}. */
|
||||
public String keyIdOf(String value) {
|
||||
String[] parts = split(value);
|
||||
return parts == null ? null : parts[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* RSA 개인키로 AES 키를 풀고 AES-GCM 으로 본문을 복호화한다.
|
||||
*
|
||||
* @throws PasswordDecryptException 형식 오류·키 불일치·태그 검증 실패
|
||||
*/
|
||||
public String decrypt(String value, PrivateKey privateKey) {
|
||||
String[] parts = split(value);
|
||||
if (parts == null) {
|
||||
throw new PasswordDecryptException("봉투 형식이 올바르지 않다");
|
||||
}
|
||||
try {
|
||||
Key aesKey = unwrapAesKey(URL_DECODER.decode(parts[2]), privateKey);
|
||||
byte[] iv = URL_DECODER.decode(parts[3]);
|
||||
byte[] cipherText = URL_DECODER.decode(parts[4]);
|
||||
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, aesKey, new GCMParameterSpec(GCM_TAG_BITS, iv));
|
||||
return new String(cipher.doFinal(cipherText), StandardCharsets.UTF_8);
|
||||
} catch (PasswordDecryptException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new PasswordDecryptException("봉투 복호화 실패", e);
|
||||
}
|
||||
}
|
||||
|
||||
private Key unwrapAesKey(byte[] wrapped, PrivateKey privateKey) {
|
||||
try {
|
||||
Cipher rsa = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
|
||||
rsa.init(Cipher.DECRYPT_MODE, privateKey, new OAEPParameterSpec(
|
||||
"SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT));
|
||||
return new SecretKeySpec(rsa.doFinal(wrapped), "AES");
|
||||
} catch (Exception e) {
|
||||
throw new PasswordDecryptException("AES 키 언랩 실패", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** {@code ENC1.keyId.encKey.iv.cipher} 5개 세그먼트. 어긋나면 null. */
|
||||
private String[] split(String value) {
|
||||
if (!isEnvelope(value)) {
|
||||
return null;
|
||||
}
|
||||
String[] parts = value.split("\\.", 5);
|
||||
if (parts.length != 5) {
|
||||
return null;
|
||||
}
|
||||
for (String part : parts) {
|
||||
if (part.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.eactive.apim.portal.common.security.passwordcrypto;
|
||||
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 비밀번호 전송암호화용 공개키 발급.
|
||||
*
|
||||
* <p>경로를 {@code /api/**} 아래에 둔 이유: {@code PortalConfigWebDispatcherServlet#addInterceptors}
|
||||
* 가 {@code /api/**} 를 인터셉터(비밀번호 변경 강제·step-up 가드)에서 제외한다. 로그인 전 익명 상태에서
|
||||
* 호출되므로 {@code /api/session/csrf} 와 같은 자리에 있어야 리다이렉트에 걸리지 않는다.</p>
|
||||
*
|
||||
* <p>GET 이라 CSRF 대상이 아니고, 공개키만 나가므로 인증도 요구하지 않는다.</p>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/security")
|
||||
public class PasswordKeyController {
|
||||
|
||||
private final PasswordCryptoProperties properties;
|
||||
private final PasswordKeyStore keyStore;
|
||||
|
||||
public PasswordKeyController(PasswordCryptoProperties properties, PasswordKeyStore keyStore) {
|
||||
this.properties = properties;
|
||||
this.keyStore = keyStore;
|
||||
}
|
||||
|
||||
@GetMapping("/password-key.json")
|
||||
public ResponseEntity<Map<String, Object>> issue(HttpServletRequest request) {
|
||||
if (!properties.isEnabled()) {
|
||||
// 클라이언트가 조용히 평문 폴백하도록 200 + enabled:false 로 답한다.
|
||||
return noStore().body(Collections.<String, Object>singletonMap("enabled", Boolean.FALSE));
|
||||
}
|
||||
|
||||
IssuedKey key = keyStore.issue(request);
|
||||
Map<String, Object> body = new java.util.LinkedHashMap<>();
|
||||
body.put("enabled", Boolean.TRUE);
|
||||
body.put("alg", "RSA-OAEP-256");
|
||||
body.put("keyId", key.getKeyId());
|
||||
body.put("publicKey", key.getPublicKey());
|
||||
body.put("expiresIn", key.getExpiresIn());
|
||||
// REQUEST 는 1회용이라 클라이언트가 캐시하면 안 된다. 나머지는 만료까지 재사용해
|
||||
// 비밀번호 검증용 ajax 가 호출마다 RSA 키쌍을 만들게 하지 않는다.
|
||||
body.put("keyScope", properties.getKeyScope().name());
|
||||
return noStore().body(body);
|
||||
}
|
||||
|
||||
private ResponseEntity.BodyBuilder noStore() {
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore());
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package com.eactive.apim.portal.common.security.passwordcrypto;
|
||||
|
||||
import java.security.KeyFactory;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.security.spec.InvalidKeySpecException;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.util.Base64;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* RSA 키쌍 생성/인코딩 헬퍼. 키 보관소 구현들이 공유한다.
|
||||
*/
|
||||
final class PasswordKeyPairs {
|
||||
|
||||
private PasswordKeyPairs() {
|
||||
}
|
||||
|
||||
static KeyPair generate(int keySize) {
|
||||
try {
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||
generator.initialize(keySize);
|
||||
return generator.generateKeyPair();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("RSA 키쌍 생성 실패", e);
|
||||
}
|
||||
}
|
||||
|
||||
static String newKeyId() {
|
||||
return UUID.randomUUID().toString().replace("-", "");
|
||||
}
|
||||
|
||||
/** 브라우저 {@code crypto.subtle.importKey('spki', ...)} 가 그대로 먹는 형식. */
|
||||
static String toSpkiBase64(PublicKey publicKey) {
|
||||
return Base64.getEncoder().encodeToString(publicKey.getEncoded());
|
||||
}
|
||||
|
||||
/**
|
||||
* PKCS#8 바이트 → PrivateKey. 세션 복제(직렬화) 환경을 고려해
|
||||
* {@link SessionScopedPasswordKeyStore} 는 키 객체 대신 바이트를 보관한다.
|
||||
*/
|
||||
static PrivateKey toPrivateKey(byte[] pkcs8) {
|
||||
try {
|
||||
return KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(pkcs8));
|
||||
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
|
||||
throw new IllegalStateException("RSA 개인키 복원 실패", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.eactive.apim.portal.common.security.passwordcrypto;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.security.PrivateKey;
|
||||
|
||||
/**
|
||||
* 비밀번호 전송암호화용 RSA 키쌍 보관소. 구현체는 {@link PasswordCryptoProperties.KeyScope} 별로 존재하며
|
||||
* {@link PasswordKeyStoreRouter} 가 설정값에 따라 위임한다.
|
||||
*/
|
||||
public interface PasswordKeyStore {
|
||||
|
||||
/** 새 공개키를 발급한다(구현에 따라 기존 키 재사용). */
|
||||
IssuedKey issue(HttpServletRequest request);
|
||||
|
||||
/** keyId 에 대응하는 개인키. 없거나 만료됐으면 {@code null}. */
|
||||
PrivateKey resolve(String keyId, HttpServletRequest request);
|
||||
|
||||
/** 요청 처리가 끝난 뒤 호출. 1회용 키를 폐기하는 구현에서만 의미가 있다. */
|
||||
void consume(String keyId, HttpServletRequest request);
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.eactive.apim.portal.common.security.passwordcrypto;
|
||||
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.security.PrivateKey;
|
||||
|
||||
/**
|
||||
* {@code portal.security.password-encrypt.key-scope} 설정값에 따라 실제 보관소로 위임한다.
|
||||
* 설정은 yml 이므로 기동 시점에 고정된다(런타임 변경 시 재기동 필요).
|
||||
*/
|
||||
@Primary
|
||||
@Component
|
||||
public class PasswordKeyStoreRouter implements PasswordKeyStore {
|
||||
|
||||
private final PasswordCryptoProperties properties;
|
||||
private final RequestScopedPasswordKeyStore requestScoped;
|
||||
private final SessionScopedPasswordKeyStore sessionScoped;
|
||||
private final ServerScopedPasswordKeyStore serverScoped;
|
||||
|
||||
public PasswordKeyStoreRouter(PasswordCryptoProperties properties,
|
||||
RequestScopedPasswordKeyStore requestScoped,
|
||||
SessionScopedPasswordKeyStore sessionScoped,
|
||||
ServerScopedPasswordKeyStore serverScoped) {
|
||||
this.properties = properties;
|
||||
this.requestScoped = requestScoped;
|
||||
this.sessionScoped = sessionScoped;
|
||||
this.serverScoped = serverScoped;
|
||||
}
|
||||
|
||||
@Override
|
||||
public IssuedKey issue(HttpServletRequest request) {
|
||||
return delegate().issue(request);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PrivateKey resolve(String keyId, HttpServletRequest request) {
|
||||
return delegate().resolve(keyId, request);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void consume(String keyId, HttpServletRequest request) {
|
||||
delegate().consume(keyId, request);
|
||||
}
|
||||
|
||||
private PasswordKeyStore delegate() {
|
||||
PasswordCryptoProperties.KeyScope scope = properties.getKeyScope();
|
||||
if (scope == PasswordCryptoProperties.KeyScope.SESSION) {
|
||||
return sessionScoped;
|
||||
}
|
||||
if (scope == PasswordCryptoProperties.KeyScope.SERVER) {
|
||||
return serverScoped;
|
||||
}
|
||||
return requestScoped;
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.eactive.apim.portal.common.security.passwordcrypto;
|
||||
|
||||
/**
|
||||
* 비밀번호 계열 파라미터명 판별. {@code plaintext-policy} 가 "평문으로 오면 안 되는 파라미터"를 가리는 데 쓴다.
|
||||
*
|
||||
* <p>실제 사용 중인 이름은 {@code password}, {@code password2}, {@code confirmPassword},
|
||||
* {@code newPassword}, {@code currentPassword}, {@code inputPassword} 로 모두 "password" 를 포함한다.
|
||||
* 향후 축약형이 생길 것을 대비해 {@code passwd}/{@code pwd} 도 함께 본다.</p>
|
||||
*/
|
||||
final class PasswordParamNames {
|
||||
|
||||
private PasswordParamNames() {
|
||||
}
|
||||
|
||||
static boolean isPasswordLike(String name) {
|
||||
if (name == null) {
|
||||
return false;
|
||||
}
|
||||
String lower = name.toLowerCase();
|
||||
return lower.contains("password") || lower.contains("passwd") || lower.contains("pwd");
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package com.eactive.apim.portal.common.security.passwordcrypto;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.security.KeyPair;
|
||||
import java.security.PrivateKey;
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 폼 진입마다 1회용 키를 발급하고 요청 1회 사용 후 폐기하는 보관소({@code key-scope: REQUEST}).
|
||||
*
|
||||
* <p>같은 keyId 로 두 번 복호화할 수 없으므로 재전송(replay) 공격이 차단된다. 개인키가 인스턴스
|
||||
* 로컬 메모리에만 있으므로 WebLogic 다중 인스턴스에서는 스티키 세션이 전제다(현재 CSRF·단일세션
|
||||
* 강제가 이미 세션 고정을 전제한다).</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class RequestScopedPasswordKeyStore implements PasswordKeyStore {
|
||||
|
||||
/** 메모리 폭주 방지 상한. 초과 시 만료 스윕 후에도 남으면 발급을 거절하지 않고 가장 오래된 것부터 버린다. */
|
||||
private static final int MAX_ENTRIES = 20_000;
|
||||
|
||||
private final PasswordCryptoProperties properties;
|
||||
private final Map<String, Entry> entries = new ConcurrentHashMap<>();
|
||||
|
||||
public RequestScopedPasswordKeyStore(PasswordCryptoProperties properties) {
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public IssuedKey issue(HttpServletRequest request) {
|
||||
sweep();
|
||||
KeyPair keyPair = PasswordKeyPairs.generate(properties.getRsaKeySize());
|
||||
String keyId = PasswordKeyPairs.newKeyId();
|
||||
int ttl = properties.getKeyTtlSeconds();
|
||||
entries.put(keyId, new Entry(keyPair.getPrivate(), System.currentTimeMillis() + ttl * 1000L));
|
||||
return new IssuedKey(keyId, PasswordKeyPairs.toSpkiBase64(keyPair.getPublic()), ttl);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PrivateKey resolve(String keyId, HttpServletRequest request) {
|
||||
Entry entry = entries.get(keyId);
|
||||
if (entry == null) {
|
||||
return null;
|
||||
}
|
||||
if (entry.expiresAt < System.currentTimeMillis()) {
|
||||
entries.remove(keyId);
|
||||
return null;
|
||||
}
|
||||
return entry.privateKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void consume(String keyId, HttpServletRequest request) {
|
||||
entries.remove(keyId);
|
||||
}
|
||||
|
||||
/** 만료 항목 정리. 발급 시점에만 돌리므로 별도 스케줄러가 필요 없다. */
|
||||
private void sweep() {
|
||||
long now = System.currentTimeMillis();
|
||||
Iterator<Map.Entry<String, Entry>> it = entries.entrySet().iterator();
|
||||
while (it.hasNext()) {
|
||||
if (it.next().getValue().expiresAt < now) {
|
||||
it.remove();
|
||||
}
|
||||
}
|
||||
if (entries.size() >= MAX_ENTRIES) {
|
||||
log.warn("비밀번호 전송암호화 키 보관소 상한 초과 - size={}, 오래된 항목을 버린다", entries.size());
|
||||
Iterator<Map.Entry<String, Entry>> overflow = entries.entrySet().iterator();
|
||||
while (overflow.hasNext() && entries.size() >= MAX_ENTRIES) {
|
||||
overflow.next();
|
||||
overflow.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Entry {
|
||||
private final PrivateKey privateKey;
|
||||
private final long expiresAt;
|
||||
|
||||
private Entry(PrivateKey privateKey, long expiresAt) {
|
||||
this.privateKey = privateKey;
|
||||
this.expiresAt = expiresAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.eactive.apim.portal.common.security.passwordcrypto;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
|
||||
/**
|
||||
* 요청이 TLS 구간으로 들어왔는지 판정한다. 경고 로그 문구를 가르는 용도다.
|
||||
*
|
||||
* <p>{@code request.isSecure()} 만 봐서는 안 된다. {@code PasswordDecryptFilter} 는 Lucy XSS 필터보다
|
||||
* 먼저 돌아야 해서 order 가 {@code Integer.MIN_VALUE} 인데, {@code ForwardedHeaderFilter}
|
||||
* ({@code PortalConfigForwardedHeader}) 도 {@code Ordered.HIGHEST_PRECEDENCE} = 같은 값이라
|
||||
* 둘의 상대 순서가 보장되지 않는다. 즉 이 시점의 {@code isSecure()} 는 {@code X-Forwarded-Proto}
|
||||
* 교정 <b>전</b> 값일 수 있다.</p>
|
||||
*
|
||||
* <p>그래서 헤더를 직접 본다. 앞단 프록시가 신뢰 경계 안이라는 전제는 {@code ForwardedHeaderFilter} 와 같다.</p>
|
||||
*/
|
||||
final class RequestTransport {
|
||||
|
||||
private RequestTransport() {
|
||||
}
|
||||
|
||||
static boolean isSecure(HttpServletRequest request) {
|
||||
String proto = request.getHeader("X-Forwarded-Proto");
|
||||
if (proto != null && !proto.isEmpty()) {
|
||||
// 프록시가 여러 단이면 "https, http" 처럼 쌓인다. 클라이언트에 가장 가까운 첫 값이 기준이다.
|
||||
int comma = proto.indexOf(',');
|
||||
String first = (comma >= 0 ? proto.substring(0, comma) : proto).trim();
|
||||
return "https".equalsIgnoreCase(first);
|
||||
}
|
||||
return request.isSecure();
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package com.eactive.apim.portal.common.security.passwordcrypto;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.security.KeyPair;
|
||||
import java.security.PrivateKey;
|
||||
|
||||
/**
|
||||
* 서버 고정 키쌍 + TTL 로테이션 구현({@code key-scope: SERVER}).
|
||||
*
|
||||
* <p>직전 키를 1개 유예 보관해, 로테이션 순간에 이미 공개키를 받아 간 폼이 제출돼도 복호화된다.
|
||||
* 재전송 방지 수단은 없으므로(같은 봉투를 여러 번 보내도 복호화됨) 인증 실패횟수 제한·계정 잠금에
|
||||
* 의존한다. 무상태라 다중 인스턴스에서도 각자 동작하지만, 인스턴스마다 키가 달라
|
||||
* 공개키 발급과 폼 제출이 같은 인스턴스로 가야 한다.</p>
|
||||
*/
|
||||
@Component
|
||||
public class ServerScopedPasswordKeyStore implements PasswordKeyStore {
|
||||
|
||||
private final PasswordCryptoProperties properties;
|
||||
|
||||
private volatile Holder current;
|
||||
private volatile Holder previous;
|
||||
|
||||
public ServerScopedPasswordKeyStore(PasswordCryptoProperties properties) {
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public IssuedKey issue(HttpServletRequest request) {
|
||||
Holder holder = currentHolder();
|
||||
int remaining = (int) Math.max(1, (holder.expiresAt - System.currentTimeMillis()) / 1000L);
|
||||
return new IssuedKey(holder.keyId, holder.publicKeySpki, remaining);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PrivateKey resolve(String keyId, HttpServletRequest request) {
|
||||
Holder holder = current;
|
||||
if (holder != null && holder.keyId.equals(keyId)) {
|
||||
return holder.privateKey;
|
||||
}
|
||||
Holder old = previous;
|
||||
if (old != null && old.keyId.equals(keyId)) {
|
||||
return old.privateKey;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void consume(String keyId, HttpServletRequest request) {
|
||||
// 고정 키라 폐기하지 않는다.
|
||||
}
|
||||
|
||||
private synchronized Holder currentHolder() {
|
||||
long now = System.currentTimeMillis();
|
||||
if (current == null || current.expiresAt < now) {
|
||||
KeyPair keyPair = PasswordKeyPairs.generate(properties.getRsaKeySize());
|
||||
previous = current;
|
||||
current = new Holder(
|
||||
PasswordKeyPairs.newKeyId(),
|
||||
keyPair.getPrivate(),
|
||||
PasswordKeyPairs.toSpkiBase64(keyPair.getPublic()),
|
||||
now + properties.getKeyTtlSeconds() * 1000L);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private static final class Holder {
|
||||
private final String keyId;
|
||||
private final PrivateKey privateKey;
|
||||
private final String publicKeySpki;
|
||||
private final long expiresAt;
|
||||
|
||||
private Holder(String keyId, PrivateKey privateKey, String publicKeySpki, long expiresAt) {
|
||||
this.keyId = keyId;
|
||||
this.privateKey = privateKey;
|
||||
this.publicKeySpki = publicKeySpki;
|
||||
this.expiresAt = expiresAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package com.eactive.apim.portal.common.security.passwordcrypto;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.io.Serializable;
|
||||
import java.security.KeyPair;
|
||||
import java.security.PrivateKey;
|
||||
|
||||
/**
|
||||
* 세션 단위로 키쌍을 보관하는 구현({@code key-scope: SESSION}).
|
||||
*
|
||||
* <p>개인키를 객체가 아니라 PKCS#8 바이트로 들고 있어 세션 복제(직렬화)에도 안전하다.
|
||||
* 같은 keyId 가 세션 수명 동안 재사용되므로 재전송 방지는 IV 랜덤성과 애플리케이션의
|
||||
* 인증 실패횟수 제한에 의존한다.</p>
|
||||
*/
|
||||
@Component
|
||||
public class SessionScopedPasswordKeyStore implements PasswordKeyStore {
|
||||
|
||||
private static final String SESSION_ATTR = "DJB_PWD_CRYPTO_KEY";
|
||||
|
||||
private final PasswordCryptoProperties properties;
|
||||
|
||||
public SessionScopedPasswordKeyStore(PasswordCryptoProperties properties) {
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public IssuedKey issue(HttpServletRequest request) {
|
||||
HttpSession session = request.getSession(true);
|
||||
Holder holder = (Holder) session.getAttribute(SESSION_ATTR);
|
||||
long now = System.currentTimeMillis();
|
||||
if (holder == null || holder.expiresAt < now) {
|
||||
KeyPair keyPair = PasswordKeyPairs.generate(properties.getRsaKeySize());
|
||||
holder = new Holder(
|
||||
PasswordKeyPairs.newKeyId(),
|
||||
keyPair.getPrivate().getEncoded(),
|
||||
PasswordKeyPairs.toSpkiBase64(keyPair.getPublic()),
|
||||
now + properties.getKeyTtlSeconds() * 1000L);
|
||||
session.setAttribute(SESSION_ATTR, holder);
|
||||
}
|
||||
int remaining = (int) Math.max(1, (holder.expiresAt - now) / 1000L);
|
||||
return new IssuedKey(holder.keyId, holder.publicKeySpki, remaining);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PrivateKey resolve(String keyId, HttpServletRequest request) {
|
||||
HttpSession session = request.getSession(false);
|
||||
if (session == null) {
|
||||
return null;
|
||||
}
|
||||
Holder holder = (Holder) session.getAttribute(SESSION_ATTR);
|
||||
if (holder == null || !holder.keyId.equals(keyId)) {
|
||||
return null;
|
||||
}
|
||||
if (holder.expiresAt < System.currentTimeMillis()) {
|
||||
session.removeAttribute(SESSION_ATTR);
|
||||
return null;
|
||||
}
|
||||
return PasswordKeyPairs.toPrivateKey(holder.privateKeyPkcs8);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void consume(String keyId, HttpServletRequest request) {
|
||||
// 세션 수명 동안 재사용한다.
|
||||
}
|
||||
|
||||
private static final class Holder implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final String keyId;
|
||||
private final byte[] privateKeyPkcs8;
|
||||
private final String publicKeySpki;
|
||||
private final long expiresAt;
|
||||
|
||||
private Holder(String keyId, byte[] privateKeyPkcs8, String publicKeySpki, long expiresAt) {
|
||||
this.keyId = keyId;
|
||||
this.privateKeyPkcs8 = privateKeyPkcs8;
|
||||
this.publicKeySpki = publicKeySpki;
|
||||
this.expiresAt = expiresAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,5 @@
|
||||
package com.eactive.apim.portal.common.util;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Base64;
|
||||
import javax.crypto.BadPaddingException;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.IllegalBlockSizeException;
|
||||
import javax.crypto.NoSuchPaddingException;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component("encryptionUtil")
|
||||
public class EncryptionUtil {
|
||||
|
||||
public static String generateNewPassword() {
|
||||
@@ -53,40 +40,4 @@ public class EncryptionUtil {
|
||||
return newpassword.toString();
|
||||
}
|
||||
|
||||
@Value("${encryption.key:kjbank_portal_application_1357902}")
|
||||
private String secretKey; // Should be 16, 24, or 32 bytes long for AES-128, AES-192, or AES-256
|
||||
|
||||
private static final String ALGORITHM = "AES";
|
||||
|
||||
private SecretKeySpec createSecretKey() {
|
||||
byte[] key = secretKey.getBytes(StandardCharsets.UTF_8);
|
||||
return new SecretKeySpec(key, ALGORITHM);
|
||||
}
|
||||
|
||||
public String encrypt(String value) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, IllegalBlockSizeException, BadPaddingException {
|
||||
if (value == null || value.isEmpty()) {
|
||||
return value;
|
||||
}
|
||||
|
||||
SecretKeySpec key = createSecretKey();
|
||||
Cipher cipher = Cipher.getInstance(ALGORITHM);
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key);
|
||||
|
||||
byte[] encryptedBytes = cipher.doFinal(value.getBytes());
|
||||
return Base64.getEncoder().encodeToString(encryptedBytes);
|
||||
}
|
||||
|
||||
public String decrypt(String encrypted) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, IllegalBlockSizeException, BadPaddingException {
|
||||
if (encrypted == null || encrypted.isEmpty()) {
|
||||
return encrypted;
|
||||
}
|
||||
|
||||
SecretKeySpec key = createSecretKey();
|
||||
Cipher cipher = Cipher.getInstance(ALGORITHM);
|
||||
cipher.init(Cipher.DECRYPT_MODE, key);
|
||||
|
||||
byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(encrypted));
|
||||
return new String(decryptedBytes);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -172,6 +172,50 @@ public class StringMaskingUtil {
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 요청 파라미터 로깅 시 값을 통째로 가릴 키. 부분일치(소문자)로 본다. */
|
||||
private static final String[] SENSITIVE_PARAM_KEYWORDS = {
|
||||
"password", "passwd", "pwd", "secret", "credential", "token"};
|
||||
|
||||
/**
|
||||
* 요청 파라미터 맵을 로그용 문자열로 만든다. 비밀번호·시크릿·토큰 계열 키는 값을 {@code [****]} 로 가린다.
|
||||
*
|
||||
* <p>비밀번호 전송암호화가 켜져 있어도 이 시점의 파라미터는 이미 복호화된 평문이므로,
|
||||
* 마스킹 없이 로깅하면 암호화 조치가 무의미해진다.</p>
|
||||
*
|
||||
* <pre>id=[user@a.com], password=[****]</pre>
|
||||
*/
|
||||
public static String maskParameterMap(java.util.Map<String, String[]> parameterMap) {
|
||||
if (parameterMap == null || parameterMap.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (java.util.Map.Entry<String, String[]> entry : parameterMap.entrySet()) {
|
||||
if (sb.length() > 0) {
|
||||
sb.append(", ");
|
||||
}
|
||||
sb.append(entry.getKey()).append('=');
|
||||
if (isSensitiveParamName(entry.getKey())) {
|
||||
sb.append("[****]");
|
||||
} else {
|
||||
sb.append(Arrays.toString(entry.getValue()));
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static boolean isSensitiveParamName(String name) {
|
||||
if (!isValidString(name)) {
|
||||
return false;
|
||||
}
|
||||
String lower = name.toLowerCase();
|
||||
for (String keyword : SENSITIVE_PARAM_KEYWORDS) {
|
||||
if (lower.contains(keyword)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 폼(application/x-www-form-urlencoded) 본문에서 값을 리댁트할 파라미터 키(소문자 완전일치)
|
||||
private static final java.util.Set<String> SENSITIVE_FORM_PARAMS = new java.util.HashSet<>(Arrays.asList(
|
||||
"client_secret", "clientsecret", "secret", "password", "passwd", "pwd",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.eactive.apim.portal.common.validator;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.commons.lang3.Strings;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.validation.ConstraintValidator;
|
||||
@@ -39,14 +40,8 @@ public class CellPhoneValidator implements ConstraintValidator<CellPhone, String
|
||||
}
|
||||
|
||||
// 기존의 prefix 체크 로직 유지
|
||||
return StringUtils.startsWith(cellPhone, "+821") ||
|
||||
StringUtils.startsWith(cellPhone, "821") ||
|
||||
StringUtils.startsWith(cellPhone, "010") ||
|
||||
StringUtils.startsWith(cellPhone, "011") ||
|
||||
StringUtils.startsWith(cellPhone, "016") ||
|
||||
StringUtils.startsWith(cellPhone, "017") ||
|
||||
StringUtils.startsWith(cellPhone, "018") ||
|
||||
StringUtils.startsWith(cellPhone, "019");
|
||||
return Strings.CS.startsWithAny(cellPhone,
|
||||
"+821", "821", "010", "011", "016", "017", "018", "019");
|
||||
}
|
||||
|
||||
private boolean isValidPartLength(String prefix, String middle, String last) {
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.eactive.apim.portal.config;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.fileupload.FileItemFactory;
|
||||
import org.apache.commons.fileupload.FileUpload;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.multipart.commons.CommonsMultipartResolver;
|
||||
@@ -18,6 +20,18 @@ import org.springframework.web.multipart.commons.CommonsMultipartResolver;
|
||||
@RequiredArgsConstructor
|
||||
public class MultipartConfig {
|
||||
|
||||
/**
|
||||
* 멀티파트 파트 1개의 헤더 구간 최대 바이트.
|
||||
* <p>
|
||||
* commons-fileupload 1.6.0(CVE-2025-48976 수정)부터 이 상한이 생겼고 기본값이 512 바이트다.
|
||||
* 한글 파일명은 UTF-8 로 3바이트/자라 Content-Disposition 이 금방 커진다 — 실측상 기본값 512 로는
|
||||
* 한글 약 137자 이상 파일명에서 업로드가 실패하고, 예외도 "Maximum upload size exceeded" 로 감싸져
|
||||
* 원인 파악이 어렵다. OS 파일명 상한(255자)이 전부 한글이어도 통과하도록 2048 로 둔다.
|
||||
* (1.5 이전에는 사실상 10240 이었으므로 이 값도 그보다 5배 엄격하다.)
|
||||
* </p>
|
||||
*/
|
||||
private static final int PART_HEADER_SIZE_MAX = 2048;
|
||||
|
||||
private final PortalProperties portalProperties;
|
||||
|
||||
/**
|
||||
@@ -31,7 +45,14 @@ public class MultipartConfig {
|
||||
*/
|
||||
@Bean(name = "filterMultipartResolver")
|
||||
public CommonsMultipartResolver filterMultipartResolver() {
|
||||
CommonsMultipartResolver resolver = new CommonsMultipartResolver();
|
||||
CommonsMultipartResolver resolver = new CommonsMultipartResolver() {
|
||||
@Override
|
||||
protected FileUpload newFileUpload(FileItemFactory fileItemFactory) {
|
||||
FileUpload fileUpload = super.newFileUpload(fileItemFactory);
|
||||
fileUpload.setPartHeaderSizeMax(PART_HEADER_SIZE_MAX);
|
||||
return fileUpload;
|
||||
}
|
||||
};
|
||||
|
||||
long maxSizeBytes = portalProperties.getFile().getMaxSizeBytes();
|
||||
resolver.setMaxUploadSize(maxSizeBytes);
|
||||
@@ -39,8 +60,8 @@ public class MultipartConfig {
|
||||
resolver.setMaxInMemorySize((int) Math.min(maxSizeBytes, Integer.MAX_VALUE));
|
||||
resolver.setDefaultEncoding("UTF-8");
|
||||
|
||||
log.info("CommonsMultipartResolver configured with maxUploadSize: {} bytes ({})",
|
||||
maxSizeBytes, portalProperties.getFile().getMaxSize());
|
||||
log.info("CommonsMultipartResolver configured with maxUploadSize: {} bytes ({}), partHeaderSizeMax: {} bytes",
|
||||
maxSizeBytes, portalProperties.getFile().getMaxSize(), PART_HEADER_SIZE_MAX);
|
||||
|
||||
return resolver;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@ package com.eactive.apim.portal.config;
|
||||
|
||||
|
||||
import com.eactive.apim.portal.apps.session.filter.SessionValidationFilter;
|
||||
import com.eactive.apim.portal.common.security.passwordcrypto.DecryptingMultipartRequestWrapper;
|
||||
import com.eactive.apim.portal.common.security.passwordcrypto.PasswordDecryptFilter;
|
||||
import com.navercorp.lucy.security.xss.servletfilter.XssEscapeServletFilter;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.ObjectPostProcessor;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
@@ -16,6 +19,7 @@ import org.springframework.security.web.access.AccessDeniedHandler;
|
||||
import org.springframework.security.web.access.AccessDeniedHandlerImpl;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.security.web.csrf.CsrfException;
|
||||
import org.springframework.security.web.header.HeaderWriterFilter;
|
||||
import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository;
|
||||
import org.springframework.security.web.session.HttpSessionEventPublisher;
|
||||
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
|
||||
@@ -62,12 +66,41 @@ public class PortalConfigSecurity {
|
||||
return registration;
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 전송암호화 복호화 필터.
|
||||
*
|
||||
* <p>order {@code MIN_VALUE + 2} — {@code PortalConfigWebDispatcherServlet} 의
|
||||
* MultipartFilter({@code MIN_VALUE + 1}) <b>이후</b>, 아래 Lucy XSS 필터
|
||||
* ({@code MIN_VALUE + 3}) <b>이전</b>에 실행되어야 한다.</p>
|
||||
*
|
||||
* <p>MultipartFilter 이후여야 하는 이유: 파일 업로드가 동반된 폼(예: 법인가입의 사업자등록증
|
||||
* 첨부)은 {@code multipart/form-data} 로 제출되는데, Spring 의 {@code MultipartHttpServletRequest}
|
||||
* 는 폼 필드 값을 자체 파싱해 {@code getParameter()} 에서 곧바로 반환하고 감싸고 있는 원본 요청으로
|
||||
* 위임하지 않는다. 이 필터가 MultipartFilter 보다 먼저 실행되어(즉 더 안쪽에서) 요청을 감싸면,
|
||||
* 멀티파트 안의 비밀번호 파라미터는 이 필터의 복호화 로직을 거치지 않고 봉투(ENC1) 문자열 그대로
|
||||
* 컨트롤러까지 전달된다({@link DecryptingMultipartRequestWrapper} 주석 참고).</p>
|
||||
*
|
||||
* <p>Lucy XSS 필터 이전이어야 하는 이유: 복호화된 평문이 기존과 똑같이 XSS 이스케이프를 거쳐야
|
||||
* 특수문자가 든 비밀번호의 해시 비교 결과가 지금과 동일하게 유지된다. 이 순서를 뒤집으면 기존
|
||||
* 계정 로그인이 깨진다.</p>
|
||||
*/
|
||||
@Bean
|
||||
public FilterRegistrationBean<PasswordDecryptFilter> passwordDecryptFilterRegistration(
|
||||
PasswordDecryptFilter filter) {
|
||||
FilterRegistrationBean<PasswordDecryptFilter> registrationBean = new FilterRegistrationBean<>(filter);
|
||||
registrationBean.setOrder(Integer.MIN_VALUE + 2);
|
||||
registrationBean.addUrlPatterns("/*");
|
||||
return registrationBean;
|
||||
}
|
||||
|
||||
// order MIN_VALUE + 3 — 위 PasswordDecryptFilter(MIN_VALUE + 2) 다음에 실행되어야
|
||||
// 복호화된 평문이 XSS 이스케이프를 거친다(순서를 뒤집으면 기존 계정 로그인이 깨짐).
|
||||
@Bean
|
||||
public FilterRegistrationBean<XssEscapeServletFilter> xssFilterRegistrationBean() {
|
||||
FilterRegistrationBean<XssEscapeServletFilter> registrationBean = new FilterRegistrationBean<>();
|
||||
XssEscapeServletFilter xssEscapeServletFilter = new XssEscapeServletFilter();
|
||||
registrationBean.setFilter(xssEscapeServletFilter);
|
||||
registrationBean.setOrder(Integer.MIN_VALUE + 1);
|
||||
registrationBean.setOrder(Integer.MIN_VALUE + 3);
|
||||
registrationBean.addUrlPatterns("/*");
|
||||
return registrationBean;
|
||||
}
|
||||
@@ -92,6 +125,23 @@ public class PortalConfigSecurity {
|
||||
|
||||
http
|
||||
.authenticationManager(portalAuthenticationManager)
|
||||
// CVE-2026-22732 우회책. Spring Security 는 기본적으로 보안 헤더를 "응답 커밋 시점"에
|
||||
// 지연 기록하는데, 응답 래퍼가 Content-Length 를 setHeader/setIntHeader/addIntHeader 로
|
||||
// 지정하는 경로를 추적하지 못해 그 경우 헤더가 통째로 누락된다
|
||||
// (X-Content-Type-Options, X-Frame-Options, Cache-Control, Pragma, Expires, X-XSS-Protection).
|
||||
// 수정본은 5.7.22/5.8.24(Enterprise 전용)뿐이라 OSS 로는 올릴 수 없어 우회책을 적용한다.
|
||||
// 요청 시작 시점에 헤더를 기록하게 만든다. 앱이 나중에 같은 헤더를 지정하면 앱 값이 남는다
|
||||
// (실측: FileDownloadController#viewImage 의 Cache-Control: public, max-age=86400 유지됨).
|
||||
// 현재 앱은 response.setContentLength(int) 만 쓰므로 노출 경로는 없지만,
|
||||
// 새 코드가 위 메서드를 쓰더라도 헤더가 빠지지 않도록 두는 안전망이다.
|
||||
// Spring Security 를 수정본(6.5.9+/7.0.4+ 또는 Enterprise 5.7.22+)으로 올리면 제거 가능.
|
||||
.headers(headers -> headers.addObjectPostProcessor(new ObjectPostProcessor<HeaderWriterFilter>() {
|
||||
@Override
|
||||
public <O extends HeaderWriterFilter> O postProcess(O filter) {
|
||||
filter.setShouldWriteHeadersEagerly(true);
|
||||
return filter;
|
||||
}
|
||||
}))
|
||||
.formLogin(form -> form
|
||||
.loginPage("/login")
|
||||
.usernameParameter("id")
|
||||
|
||||
+24
-11
@@ -175,31 +175,44 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
||||
* {@code /css/main.css} 요청을 내용 해시가 포함된 {@code /css/main-<hash>.css} 로 매핑한다.
|
||||
* 내용이 바뀌면 URL 이 바뀌므로 강제 새로고침 없이 브라우저 캐시가 무효화된다. Thymeleaf 의
|
||||
* {@code @{/css/main.css}} 링크는 {@link #resourceUrlEncodingFilter()} 가 해시 URL 로 치환한다.</p>
|
||||
*
|
||||
* <p>버전닝이 꺼져 있으면(prod 포함 — {@link #isResourceVersioningEnabled()} 참고) URL 이
|
||||
* 고정이라 장기 {@code max-age} 캐시를 쓰면 배포 후에도 브라우저가 옛 내용을 계속 쓸 위험이 있다.
|
||||
* 이 경우 {@code no-cache}(매 요청 {@code If-Modified-Since} 조건부 재검증, 변경 없으면 304)로
|
||||
* 신선도를 보장한다 — OHS 가 정적 리소스를 직접 서빙해도 Apache 가 파일 mtime 기준으로 동일하게
|
||||
* Last-Modified/조건부 GET 을 처리하므로 WAS 와 동작이 어긋나지 않는다
|
||||
* (배경: djb-obsidian-docs/3000-테스트/15-Static리소스분리).</p>
|
||||
*/
|
||||
private void addStaticResourceHandler(ResourceHandlerRegistry registry, String pattern, String... locations) {
|
||||
// 캐싱 ON(prod) 이면 브라우저에 1일 캐시를, OFF(dev/local) 면 no-store 를 내려보낸다.
|
||||
// no-store 는 브라우저가 아예 저장하지 않으므로 강제 새로고침 없이 sass/JS 변경이 바로 보인다.
|
||||
CacheControl cacheControl = isResourceCachingEnabled()
|
||||
? CacheControl.maxAge(1, TimeUnit.DAYS)
|
||||
: CacheControl.noStore();
|
||||
boolean versioningOn = isResourceVersioningEnabled();
|
||||
CacheControl cacheControl;
|
||||
if (!isResourceCachingEnabled()) {
|
||||
// no-store: 브라우저가 아예 저장하지 않으므로 강제 새로고침 없이 sass/JS 변경이 바로 보인다(dev/local).
|
||||
cacheControl = CacheControl.noStore();
|
||||
} else if (versioningOn) {
|
||||
cacheControl = CacheControl.maxAge(1, TimeUnit.DAYS);
|
||||
} else {
|
||||
cacheControl = CacheControl.noCache();
|
||||
}
|
||||
ResourceChainRegistration chain = registry.addResourceHandler(pattern)
|
||||
.addResourceLocations(locations)
|
||||
.setCacheControl(cacheControl)
|
||||
.resourceChain(isResourceCachingEnabled());
|
||||
if (isResourceVersioningEnabled()) {
|
||||
if (versioningOn) {
|
||||
chain.addResolver(new VersionResourceResolver().addContentVersionStrategy("/**"));
|
||||
}
|
||||
chain.addResolver(new PathResourceResolver());
|
||||
}
|
||||
|
||||
/**
|
||||
* 정적자원 해시 버전닝 활성 여부. prod 프로파일은 토글과 무관하게 항상 ON,
|
||||
* 그 외 프로파일은 {@code app.resource-versioning.enabled} 값을 따른다.
|
||||
* 정적자원 해시 버전닝 활성 여부. {@code app.resource-versioning.enabled} 값을 그대로 따른다.
|
||||
*
|
||||
* <p>과거 prod 프로파일은 이 토글과 무관하게 항상 ON 이었으나, OHS 가 정적 리소스를 직접
|
||||
* 서빙하는 구조로 바뀌면서 OFF 로 전환했다 — 해시가 붙은 URL(main-<hash>.css)이 OHS
|
||||
* 디스크의 무해시 원본과 어긋나 404 가 날 수 있어서다(prod.yml 에서 명시적으로 false 설정).
|
||||
* 대신 캐시 신선도는 {@link #addStaticResourceHandler} 의 no-cache 조건부 재검증으로 보장한다.</p>
|
||||
*/
|
||||
private boolean isResourceVersioningEnabled() {
|
||||
if (environment.acceptsProfiles(Profiles.of("prod"))) {
|
||||
return true;
|
||||
}
|
||||
return resourceVersioningEnabled;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,11 +24,11 @@ public class RestTemplateConfig {
|
||||
public RestTemplate restTemplate() {
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
|
||||
// 연결 타임아웃: 10초
|
||||
factory.setConnectTimeout(10000);
|
||||
// 연결 타임아웃: 5초 (admin base-url failover 시 다음 후보로 빨리 넘어가기 위함)
|
||||
factory.setConnectTimeout(5000);
|
||||
|
||||
// 읽기 타임아웃: 30초
|
||||
factory.setReadTimeout(30000);
|
||||
// 읽기 타임아웃: 5초
|
||||
factory.setReadTimeout(5000);
|
||||
|
||||
return new RestTemplate(factory);
|
||||
}
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ import java.util.stream.Collectors;
|
||||
*/
|
||||
@Slf4j
|
||||
@Controller
|
||||
@RequestMapping("//notitest")
|
||||
@RequestMapping("/djb/notitest")
|
||||
@RequiredArgsConstructor
|
||||
public class NotiTestController {
|
||||
|
||||
|
||||
@@ -40,6 +40,12 @@ gateway:
|
||||
|
||||
portal:
|
||||
# auth-virtual-code: 654321
|
||||
security:
|
||||
# 개발 서버는 HTTP(비 secure context)라 crypto.subtle 이 없다. software-fallback(forge)으로 암호화한다.
|
||||
password-encrypt:
|
||||
enabled: true
|
||||
# 개발 서버는 원격 오리진 HTTP → crypto.subtle 이 없다. 기본값인 software-fallback(forge)으로 암호화된다.
|
||||
plaintext-policy: permissive
|
||||
dev:
|
||||
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
|
||||
hot-reload-pages: true
|
||||
|
||||
@@ -9,9 +9,11 @@ spring:
|
||||
cachecontrol:
|
||||
max-age: 86400
|
||||
public: true
|
||||
# 정적자원 콘텐츠 해시 버전닝은 PortalConfigWebDispatcherServlet 가 prod 프로파일에서
|
||||
# 항상 ON 으로 수행한다(app.resource-versioning.enabled 토글 무시).
|
||||
# 해시 URL + 장기 캐시(max-age 86400)로 배포 시 자동 캐시 무효화.
|
||||
# 정적자원 콘텐츠 해시 버전닝: OHS 가 정적 리소스(css/js/img 등)를 직접 서빙하는 구조에서는
|
||||
# 해시 URL(main-<hash>.css)이 OHS 디스크의 무해시 원본과 어긋나 404 위험이 있어 OFF 로 전환했다
|
||||
# (아래 app.resource-versioning.enabled: false, PortalConfigWebDispatcherServlet 참고).
|
||||
# 캐시 무효화는 대신 no-cache(매 요청 If-Modified-Since 조건부 재검증)로 보장한다.
|
||||
# 배경: djb-obsidian-docs/3000-테스트/15-Static리소스분리
|
||||
jpa:
|
||||
properties:
|
||||
hibernate:
|
||||
@@ -28,7 +30,7 @@ spring:
|
||||
|
||||
app:
|
||||
resource-versioning:
|
||||
enabled: true
|
||||
enabled: false
|
||||
resource-caching:
|
||||
enabled: true
|
||||
|
||||
@@ -37,3 +39,14 @@ server:
|
||||
# 상세 주석은 application.yml 의 동일 키 참고. /internal/** 은 필터에서 제외된다
|
||||
# (PortalConfigForwardedHeader — 원 소켓 IP 기반 허용 IP 검사를 보존하기 위함).
|
||||
forward-headers-strategy: framework
|
||||
|
||||
portal:
|
||||
security:
|
||||
# 비밀번호 전송암호화. HTTPS 구간이라 켠다.
|
||||
password-encrypt:
|
||||
enabled: true
|
||||
# 운영은 평문 비밀번호를 받지 않는다. 봉투가 아니면 빈 값으로 치환돼 인증 실패로 떨어진다.
|
||||
# 대가: JS 를 끈 브라우저는 로그인할 수 없고, 공개키 발급(/api/security/password-key.json)이
|
||||
# 죽으면 로그인 전체가 막힌다(permissive 면 평문으로 degrade 되어 로그인은 됐을 상황).
|
||||
# 되돌리려면 이 값을 permissive 로 바꾸고 재기동해야 한다 — yml 이라 무중단 토글은 안 된다.
|
||||
plaintext-policy: enforce
|
||||
|
||||
@@ -22,6 +22,11 @@ spring:
|
||||
portal:
|
||||
# 검증 단계에선 사용하지 않음
|
||||
# auth-virtual-code: 654321
|
||||
security:
|
||||
# HTTPS 구간이라 전송암호화를 켠다. plaintext-policy 승격(ENFORCE)은 운영 승격 전 검증 후 판단.
|
||||
password-encrypt:
|
||||
enabled: true
|
||||
plaintext-policy: permissive
|
||||
dev:
|
||||
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
|
||||
hot-reload-pages: true
|
||||
|
||||
@@ -153,6 +153,54 @@ portal:
|
||||
user-approval: true
|
||||
password-expiration-days: 90
|
||||
|
||||
security:
|
||||
# 비밀번호 전송암호화(RSA-OAEP + AES-GCM). 개발자도구 Network 탭 평문 노출 대응.
|
||||
#
|
||||
# 화면(password-crypto.js)이 서버 공개키로 AES 키를 감싸고 비밀번호를 그 AES 키로 암호화해
|
||||
# ENC1.<keyId>.<랩된AES키>.<iv>.<암호문+태그> 봉투로 보낸다. 서버는 PasswordDecryptFilter 가
|
||||
# 파라미터를 평문으로 되돌리므로 컨트롤러는 이 기능을 몰라도 된다.
|
||||
#
|
||||
# 주의: XSS 방어도 MITM 방어도 아니다. 스크립트가 주입되면 입력창에서 직접 털리고,
|
||||
# HTTP 구간이면 중간자가 이 스크립트 자체를 바꿔치기할 수 있다.
|
||||
#
|
||||
# 아래는 모든 키의 기본값이다. 프로파일 yml 에는 이 값과 다른 것만 적는다.
|
||||
password-encrypt:
|
||||
# 마스터 스위치. false 면 필터·키 발급 엔드포인트가 모두 무동작이고 화면은 평문 전송한다.
|
||||
# 기본은 꺼둔다 — 켜는 것은 프로파일 yml 의 판단.
|
||||
enabled: false
|
||||
|
||||
# RSA 개인키 보관 범위.
|
||||
# REQUEST - 폼 진입마다 1회용 키 발급, 요청 1회 사용 후 폐기. 재전송 차단이 가장 강하다.
|
||||
# SESSION - 세션 단위 보관. 세션 복제 환경에 유리.
|
||||
# SERVER - 서버 고정 키쌍 + TTL 로테이션. 무상태라 단순하지만 재전송 방지 수단이 없다.
|
||||
key-scope: REQUEST
|
||||
|
||||
# 발급된 키의 수명(초). REQUEST/SESSION 은 만료 기준, SERVER 는 로테이션 주기.
|
||||
key-ttl-seconds: 300
|
||||
|
||||
# 봉투가 씌워지지 않은 '평문 비밀번호 파라미터' 를 서버가 어떻게 다룰지.
|
||||
# 전송 구간(HTTP/HTTPS)을 막는 스위치가 아니다 — 전송 구간은 경고 문구에만 영향을 준다.
|
||||
# none - 무동작. 평문을 그대로 받는다.
|
||||
# permissive - 평문을 받되 서버 로그에 경고를 남기고(요청당 1줄), 화면에도 경고 팝업을 띄운다.
|
||||
# HTTP 접속이면 비밀번호 입력 화면에서 세션당 1회 팝업이 뜬다.
|
||||
# enforce - 평문 비밀번호를 거부한다(빈 값 치환 → 인증 실패).
|
||||
# JS 를 끈 브라우저는 로그인하지 못하므로, permissive 로 운영하며 위 경고 로그가
|
||||
# 안 나오는 것을 확인한 뒤에 승격한다.
|
||||
# 기본은 none — 어느 환경에 올려도 기존 동작이 바뀌지 않게 한다.
|
||||
# (enabled: false 면 필터가 통째로 무동작이라 이 값은 어차피 영향이 없다.)
|
||||
plaintext-policy: none
|
||||
|
||||
# crypto.subtle 을 쓸 수 없는 환경에서 순수 JS 구현(forge)으로 봉투를 만들지 여부.
|
||||
# 브라우저는 secure context(HTTPS·localhost)가 아니면 crypto.subtle 을 아예 노출하지 않는다.
|
||||
# 즉 원격 오리진 HTTP(사내 IP 접속 등)에서는 이 값을 켜야 암호화가 걸린다.
|
||||
# forge 번들(js/lib/forge-crypto.min.js, gzip 약 31KB)은 crypto.subtle 이 없을 때만 동적으로
|
||||
# 내려가므로 HTTPS 구간에서는 전송 바이트가 0 이다. 사실상 킬스위치.
|
||||
# 기본은 true — HTTPS 구간에서는 어차피 내려가지 않고, HTTP 구간에서는 켜져 있어야 의미가 있다.
|
||||
software-fallback: true
|
||||
|
||||
# RSA 키 길이.
|
||||
rsa-key-size: 2048
|
||||
|
||||
pages:
|
||||
- path-pattern: /dashboard/daily_usage
|
||||
method: GET
|
||||
|
||||
@@ -1247,7 +1247,7 @@ hr {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.mobile-drawer .drawer-welcome .btn-drawer-login:hover {
|
||||
background: rgb(0%, 25.7647058824%, 63.5294117647%);
|
||||
background: rgb(0, 65.7, 162);
|
||||
}
|
||||
.mobile-drawer .drawer-welcome.authenticated {
|
||||
flex-direction: row;
|
||||
@@ -2574,7 +2574,7 @@ hr {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
.btn-success:hover {
|
||||
background: rgb(32.662665066%, 78.1608643457%, 41.762304922%);
|
||||
background: rgb(83.2897959184, 199.3102040816, 106.493877551);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
.btn-danger {
|
||||
@@ -2582,7 +2582,7 @@ hr {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background: rgb(100%, 27.7647058824%, 27.7647058824%);
|
||||
background: rgb(255, 70.8, 70.8);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
.btn-ghost {
|
||||
@@ -2848,7 +2848,7 @@ hr {
|
||||
.action-btn-delete:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
|
||||
background: rgb(100%, 34.862745098%, 34.862745098%);
|
||||
background: rgb(255, 88.9, 88.9);
|
||||
}
|
||||
.action-btn-delete:active {
|
||||
transform: translateY(0);
|
||||
@@ -2966,7 +2966,7 @@ hr {
|
||||
background: #a4d6ea;
|
||||
}
|
||||
.btn-input-action.btn-change:hover {
|
||||
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
|
||||
background: rgb(131.6625, 199.4303571429, 226.5375);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
|
||||
}
|
||||
@@ -3041,7 +3041,7 @@ hr {
|
||||
border: none;
|
||||
}
|
||||
.btn-action-primary:hover {
|
||||
background: rgb(12.4992826399%, 36.3615494978%, 80.6771879484%);
|
||||
background: rgb(31.8731707317, 92.7219512195, 205.7268292683);
|
||||
transform: translateY(-2px);
|
||||
color: #fff;
|
||||
}
|
||||
@@ -3091,7 +3091,7 @@ hr {
|
||||
}
|
||||
.status-badge.status-processing {
|
||||
background: rgba(255, 217, 61, 0.1);
|
||||
color: rgb(86.7450980392%, 69.7537901759%, 0%);
|
||||
color: rgb(221.2, 177.8721649485, 0);
|
||||
}
|
||||
.status-badge.status-failed {
|
||||
background: rgba(255, 107, 107, 0.1);
|
||||
@@ -3131,7 +3131,7 @@ hr {
|
||||
}
|
||||
.status-badge-header.status-processing {
|
||||
background: rgba(255, 217, 61, 0.1);
|
||||
color: rgb(86.7450980392%, 69.7537901759%, 0%);
|
||||
color: rgb(221.2, 177.8721649485, 0);
|
||||
}
|
||||
|
||||
.badge-sm {
|
||||
@@ -4319,7 +4319,7 @@ select.form-control {
|
||||
.file-upload-wrapper .file-remove-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
|
||||
background: rgb(100%, 34.862745098%, 34.862745098%);
|
||||
background: rgb(255, 88.9, 88.9);
|
||||
}
|
||||
.file-upload-wrapper .file-remove-btn:active {
|
||||
transform: translateY(0);
|
||||
@@ -4640,7 +4640,7 @@ select.form-control {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.form-actions--with-withdrawal .withdrawal-link:hover {
|
||||
background: rgb(82.4349376114%, 91.2174688057%, 95.2709447415%);
|
||||
background: rgb(210.2090909091, 232.6045454545, 242.9409090909);
|
||||
}
|
||||
.form-actions--with-withdrawal .withdrawal-link img {
|
||||
width: 22px;
|
||||
@@ -4795,7 +4795,7 @@ select.form-control {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.notice-content-box a:hover {
|
||||
color: rgb(0%, 25.7647058824%, 63.5294117647%);
|
||||
color: rgb(0, 65.7, 162);
|
||||
}
|
||||
|
||||
.form-row--content .form-label-wrapper {
|
||||
@@ -5067,6 +5067,8 @@ select.form-control {
|
||||
color: #212529;
|
||||
font-size: 20px;
|
||||
line-height: 1.6;
|
||||
word-break: keep-all;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.modal-body p {
|
||||
margin: 0;
|
||||
@@ -5733,7 +5735,7 @@ select.form-control {
|
||||
font-size: 16px;
|
||||
}
|
||||
.drawer-logout-btn:hover {
|
||||
background: rgb(100%, 27.7647058824%, 27.7647058824%);
|
||||
background: rgb(255, 70.8, 70.8);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(75, 155, 255, 0.15);
|
||||
}
|
||||
@@ -6447,7 +6449,7 @@ select.form-control {
|
||||
color: #64748b;
|
||||
}
|
||||
.list-table-btn--default:hover {
|
||||
background-color: rgb(91.512605042%, 91.512605042%, 90.6638655462%);
|
||||
background-color: rgb(233.3571428571, 233.3571428571, 231.1928571429);
|
||||
}
|
||||
.list-table-btn--primary {
|
||||
background-color: #ecf0fa;
|
||||
@@ -6455,7 +6457,7 @@ select.form-control {
|
||||
color: #2a69de;
|
||||
}
|
||||
.list-table-btn--primary:hover {
|
||||
background-color: rgb(85.0049019608%, 88.1617647059%, 96.0539215686%);
|
||||
background-color: rgb(216.7625, 224.8125, 244.9375);
|
||||
}
|
||||
.list-table-btn--secondary {
|
||||
background-color: #f5f5f4;
|
||||
@@ -6463,7 +6465,7 @@ select.form-control {
|
||||
color: #64748b;
|
||||
}
|
||||
.list-table-btn--secondary:hover {
|
||||
background-color: rgb(91.512605042%, 91.512605042%, 90.6638655462%);
|
||||
background-color: rgb(233.3571428571, 233.3571428571, 231.1928571429);
|
||||
}
|
||||
.list-table-btn--danger {
|
||||
background-color: #fbe7e9;
|
||||
@@ -6471,7 +6473,7 @@ select.form-control {
|
||||
color: #bb1026;
|
||||
}
|
||||
.list-table-btn--danger:hover {
|
||||
background-color: rgb(97.081232493%, 82.487394958%, 83.9467787115%);
|
||||
background-color: rgb(247.5571428571, 210.3428571429, 214.0642857143);
|
||||
}
|
||||
|
||||
.table-pagination {
|
||||
@@ -7121,7 +7123,7 @@ select.form-control {
|
||||
.alert.alert-error {
|
||||
background: rgba(255, 107, 107, 0.1);
|
||||
border: 1px solid rgba(255, 107, 107, 0.3);
|
||||
color: rgb(100%, 27.7647058824%, 27.7647058824%);
|
||||
color: rgb(255, 70.8, 70.8);
|
||||
align-items: center;
|
||||
}
|
||||
.alert.alert-error svg {
|
||||
@@ -7135,7 +7137,7 @@ select.form-control {
|
||||
.alert.alert-success {
|
||||
background: rgba(107, 207, 127, 0.1);
|
||||
border: 1px solid rgba(107, 207, 127, 0.3);
|
||||
color: rgb(24.12484994%, 74.3849539816%, 34.1768707483%);
|
||||
color: rgb(61.5183673469, 189.6816326531, 87.1510204082);
|
||||
}
|
||||
.alert.alert-info {
|
||||
background: rgba(0, 73, 180, 0.1);
|
||||
@@ -11549,10 +11551,10 @@ body.index-page-body {
|
||||
line-height: 20px;
|
||||
}
|
||||
.login-button:hover {
|
||||
background: rgb(10.0588235294%, 27.568627451%, 68.1764705882%);
|
||||
background: rgb(25.65, 70.3, 173.85);
|
||||
}
|
||||
.login-button:active {
|
||||
background: rgb(9.5294117647%, 26.1176470588%, 64.5882352941%);
|
||||
background: rgb(24.3, 66.6, 164.7);
|
||||
}
|
||||
.login-button:disabled {
|
||||
opacity: 0.6;
|
||||
@@ -11595,10 +11597,10 @@ body.index-page-body {
|
||||
border-bottom-right-radius: 8px;
|
||||
}
|
||||
.login-links-container .link-btn:hover {
|
||||
background: rgb(86.5137254902%, 89.3529411765%, 96.4509803922%);
|
||||
background: rgb(220.61, 227.85, 245.95);
|
||||
}
|
||||
.login-links-container .link-btn:active {
|
||||
background: rgb(80.4784313725%, 84.5882352941%, 94.862745098%);
|
||||
background: rgb(205.22, 215.7, 241.9);
|
||||
}
|
||||
|
||||
.login-alert {
|
||||
@@ -12107,12 +12109,12 @@ body.index-page-body {
|
||||
}
|
||||
.auth-request-button:hover,
|
||||
.auth-verify-button:hover {
|
||||
background: rgb(14.6320689023%, 60.3648891332%, 92.1600879604%);
|
||||
background: rgb(37.3117757009, 153.9304672897, 235.0082242991);
|
||||
transform: none !important;
|
||||
}
|
||||
.auth-request-button:active,
|
||||
.auth-verify-button:active {
|
||||
background: rgb(8.3967014843%, 57.3774601429%, 91.4307494961%);
|
||||
background: rgb(21.411588785, 146.3125233645, 233.148411215);
|
||||
}
|
||||
.auth-request-button:disabled,
|
||||
.auth-verify-button:disabled {
|
||||
@@ -12161,10 +12163,10 @@ body.index-page-body {
|
||||
background: #f0f2f5;
|
||||
}
|
||||
.account-recovery-card .form-actions .cancel-button:hover {
|
||||
background: rgb(88.4117647059%, 89.9568627451%, 92.2745098039%);
|
||||
background: rgb(225.45, 229.39, 235.3);
|
||||
}
|
||||
.account-recovery-card .form-actions .cancel-button:active {
|
||||
background: rgb(82.7058823529%, 85.0117647059%, 88.4705882353%);
|
||||
background: rgb(210.9, 216.78, 225.6);
|
||||
}
|
||||
.account-recovery-card .form-actions .submit-button {
|
||||
color: #FFFFFF;
|
||||
@@ -12174,7 +12176,7 @@ body.index-page-body {
|
||||
background: rgb(6, 54, 125);
|
||||
}
|
||||
.account-recovery-card .form-actions .submit-button:active {
|
||||
background: rgb(0%, 25.7647058824%, 63.5294117647%);
|
||||
background: rgb(0, 65.7, 162);
|
||||
}
|
||||
.account-recovery-card .form-actions .submit-button:disabled {
|
||||
opacity: 0.6;
|
||||
@@ -12410,7 +12412,7 @@ body.index-page-body {
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
.result-info-box .info-text .info-link:hover {
|
||||
color: rgb(0%, 25.7647058824%, 63.5294117647%);
|
||||
color: rgb(0, 65.7, 162);
|
||||
}
|
||||
@media (max-width: 576px) {
|
||||
.result-info-box .info-text {
|
||||
@@ -17610,7 +17612,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
.btn-copy-action:hover {
|
||||
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
|
||||
background: rgb(131.6625, 199.4303571429, 226.5375);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.btn-copy-action {
|
||||
@@ -17637,7 +17639,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
.btn-view-secret:hover {
|
||||
background: rgb(12.5057724024%, 59.377680044%, 91.9648158329%);
|
||||
background: rgb(31.8897196262, 151.4130841121, 234.5102803738);
|
||||
}
|
||||
.btn-view-secret svg {
|
||||
width: 20px;
|
||||
@@ -17822,7 +17824,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
border-radius: 8px;
|
||||
}
|
||||
.btn-copy-action:hover {
|
||||
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
|
||||
background: rgb(131.6625, 199.4303571429, 226.5375);
|
||||
}
|
||||
.btn-view-secret {
|
||||
width: 100% !important;
|
||||
@@ -17838,7 +17840,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
height: 16px;
|
||||
}
|
||||
.btn-view-secret:hover {
|
||||
background: rgb(12.5057724024%, 59.377680044%, 91.9648158329%);
|
||||
background: rgb(31.8897196262, 151.4130841121, 234.5102803738);
|
||||
}
|
||||
#revealedSecretBox {
|
||||
width: 100%;
|
||||
@@ -18111,7 +18113,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.detail-wrap .dt-btn-copy:hover {
|
||||
background: rgb(74.3529411765%, 90.2296918768%, 100%);
|
||||
background: rgb(189.6, 230.0857142857, 255);
|
||||
}
|
||||
.detail-wrap .dt-btn-copy svg {
|
||||
color: #2a69de;
|
||||
@@ -18292,7 +18294,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
.detail-wrap .dt-btn-gray:hover {
|
||||
background: rgb(66.9250773994%, 71.9364293086%, 75.9455108359%);
|
||||
background: rgb(170.6589473684, 183.4378947368, 193.6610526316);
|
||||
}
|
||||
.detail-wrap .dt-btn-red {
|
||||
width: 156px;
|
||||
@@ -18310,7 +18312,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
.detail-wrap .dt-btn-red:hover {
|
||||
background: rgb(100%, 27.4868759774%, 25.1921568627%);
|
||||
background: rgb(255, 70.0915337423, 64.24);
|
||||
}
|
||||
.detail-wrap .dt-btn-blue {
|
||||
width: 156px;
|
||||
@@ -19884,7 +19886,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
}
|
||||
}
|
||||
.btn-inquiry-list:hover {
|
||||
background: rgb(84.6615515772%, 85.8414322251%, 88.2011935209%);
|
||||
background: rgb(215.8869565217, 218.8956521739, 224.9130434783);
|
||||
}
|
||||
.btn-inquiry-list:active {
|
||||
transform: scale(0.98);
|
||||
@@ -19917,7 +19919,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
}
|
||||
}
|
||||
.btn-inquiry-edit:hover {
|
||||
background: rgb(0%, 27.1960784314%, 67.0588235294%);
|
||||
background: rgb(0, 69.35, 171);
|
||||
}
|
||||
.btn-inquiry-edit:active {
|
||||
transform: scale(0.98);
|
||||
@@ -19950,7 +19952,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
}
|
||||
}
|
||||
.btn-inquiry-delete:hover {
|
||||
background: rgb(85.4839910648%, 16.2218912882%, 22.8577810871%);
|
||||
background: rgb(217.9841772152, 41.3658227848, 58.2873417722);
|
||||
}
|
||||
.btn-inquiry-delete:active {
|
||||
transform: scale(0.98);
|
||||
@@ -20022,7 +20024,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
margin-left: 8px;
|
||||
}
|
||||
.file-upload-inline .btn-remove-file-inline:hover {
|
||||
background: rgb(82.1236038719%, 14.2293373045%, 20.7341772152%);
|
||||
background: rgb(209.4151898734, 36.2848101266, 52.8721518987);
|
||||
}
|
||||
.file-upload-inline .btn-remove-file-inline svg {
|
||||
width: 12px;
|
||||
@@ -20054,7 +20056,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
}
|
||||
}
|
||||
.file-upload-inline .btn-file-attach:hover {
|
||||
background: rgb(14.6320689023%, 60.3648891332%, 92.1600879604%);
|
||||
background: rgb(37.3117757009, 153.9304672897, 235.0082242991);
|
||||
}
|
||||
.file-upload-inline .btn-file-attach svg {
|
||||
width: 22px;
|
||||
@@ -20114,7 +20116,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
border: none;
|
||||
}
|
||||
.inquiry-form-container .form-actions .btn-secondary:hover {
|
||||
background: rgb(84.6615515772%, 85.8414322251%, 88.2011935209%);
|
||||
background: rgb(215.8869565217, 218.8956521739, 224.9130434783);
|
||||
}
|
||||
.inquiry-form-container .form-actions .btn-primary {
|
||||
background: #0049b4;
|
||||
@@ -20122,7 +20124,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
border: none;
|
||||
}
|
||||
.inquiry-form-container .form-actions .btn-primary:hover {
|
||||
background: rgb(0%, 27.1960784314%, 67.0588235294%);
|
||||
background: rgb(0, 69.35, 171);
|
||||
}
|
||||
.inquiry-form-container .file-upload-inline .file-input-display {
|
||||
min-height: 50px;
|
||||
@@ -20911,7 +20913,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
cursor: pointer;
|
||||
}
|
||||
.djb-board-write-container .form-actions .btn-submit:hover {
|
||||
background-color: rgb(13.193687231%, 38.3816355811%, 85.1592539455%);
|
||||
background-color: rgb(33.643902439, 97.8731707317, 217.156097561);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.djb-board-write-container .form-actions .btn-submit {
|
||||
@@ -21447,7 +21449,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.org-file-remove:hover {
|
||||
background: rgb(100%, 27.7647058824%, 27.7647058824%);
|
||||
background: rgb(255, 70.8, 70.8);
|
||||
}
|
||||
|
||||
.org-file-notice {
|
||||
@@ -22488,7 +22490,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
}
|
||||
.status-indicator.status-active {
|
||||
background-color: rgba(107, 207, 127, 0.1);
|
||||
color: rgb(32.662665066%, 78.1608643457%, 41.762304922%);
|
||||
color: rgb(83.2897959184, 199.3102040816, 106.493877551);
|
||||
}
|
||||
.status-indicator.status-active .status-dot {
|
||||
background-color: #6BCF7F;
|
||||
@@ -25273,6 +25275,34 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.api-statistics-container .statistics-date-range {
|
||||
width: 260px;
|
||||
max-width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
.api-statistics-container .statistics-date-range:focus-visible {
|
||||
outline: 2px solid #0049B4;
|
||||
outline-offset: 2px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.daterangepicker.statistics-date-picker {
|
||||
z-index: 1100;
|
||||
max-width: calc(100vw - 16px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.daterangepicker.statistics-date-picker .applyBtn {
|
||||
color: #fff;
|
||||
background: #0049B4;
|
||||
border-radius: 4px;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.daterangepicker.statistics-date-picker {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-search {
|
||||
background: #0049B4;
|
||||
border: none;
|
||||
@@ -27135,7 +27165,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
}
|
||||
.api-status .as-alert-card .as-tl-row {
|
||||
display: grid;
|
||||
grid-template-columns: 60px 76px 1fr;
|
||||
grid-template-columns: 60px 76px 64px 1fr;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -27143,6 +27173,37 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
color: var(--as-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.api-status .as-alert-card .as-tl-row .as-tl-state {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
height: -webkit-fit-content;
|
||||
height: fit-content;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--as-pill);
|
||||
background: var(--as-gray-bg);
|
||||
color: var(--as-text-2);
|
||||
}
|
||||
.api-status .as-alert-card .as-tl-row .as-tl-state.state-INVESTIGATING {
|
||||
background: var(--as-err-bg);
|
||||
color: var(--as-err);
|
||||
}
|
||||
.api-status .as-alert-card .as-tl-row .as-tl-state.state-IDENTIFIED {
|
||||
background: #fbe9d7;
|
||||
color: #c95a0f;
|
||||
}
|
||||
.api-status .as-alert-card .as-tl-row .as-tl-state.state-MONITORING {
|
||||
background: var(--as-warn-bg);
|
||||
color: var(--as-warn);
|
||||
}
|
||||
.api-status .as-alert-card .as-tl-row .as-tl-state.state-RESOLVED {
|
||||
background: var(--as-ok-bg);
|
||||
color: var(--as-ok);
|
||||
}
|
||||
.api-status .as-alert-card .as-tl-row .as-tl-state.state-CANCELED {
|
||||
background: var(--as-gray-bg);
|
||||
color: var(--as-muted);
|
||||
}
|
||||
.api-status .as-alert-card .as-tl-row .as-tl-who {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
@@ -27292,9 +27353,6 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
padding: 22px 24px;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
.api-status .as-maint-card.is-ongoing {
|
||||
border-left-color: var(--as-warn);
|
||||
}
|
||||
.api-status .as-maint-card .as-maint-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@@ -27306,7 +27364,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--as-text);
|
||||
color: var(--as-info);
|
||||
}
|
||||
.api-status .as-maint-card .as-schedule {
|
||||
display: inline-flex;
|
||||
@@ -27320,10 +27378,6 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
border-radius: var(--as-pill);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.api-status .as-maint-card.is-ongoing .as-schedule {
|
||||
color: var(--as-warn);
|
||||
background: var(--as-warn-bg);
|
||||
}
|
||||
.api-status .as-maint-card .as-maint-body {
|
||||
margin: 14px 0 12px;
|
||||
font-size: 14px;
|
||||
@@ -27720,7 +27774,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.api-status .as-alert-card .as-tl-row {
|
||||
grid-template-columns: 52px 1fr;
|
||||
grid-template-columns: 52px 68px 1fr;
|
||||
}
|
||||
.api-status .as-alert-card .as-tl-who {
|
||||
display: none;
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
@@ -1,491 +0,0 @@
|
||||
class ChartEditor {
|
||||
constructor(container, layoutManager, dashboard) {
|
||||
this.rootContainer = container;
|
||||
this.layoutManager = layoutManager;
|
||||
this.dashboard = dashboard;
|
||||
this.targetContainerIndex = -1;
|
||||
this.targetColumnIndex = -1;
|
||||
this.tempChart = null;
|
||||
this.chartPreview = null;
|
||||
this.containerHeight = 0;
|
||||
|
||||
this.chartEditorModel = [
|
||||
{
|
||||
'groupName': 'default',
|
||||
'groupTitle': '기본 정보',
|
||||
'fields': [
|
||||
{
|
||||
'path': 'contentId',
|
||||
'type': 'text',
|
||||
'label': 'Chart ID',
|
||||
'placeholder': 'chartId를 입력하세요'
|
||||
},
|
||||
{
|
||||
'path': 'styles.width',
|
||||
'type': 'text',
|
||||
'label': '길이(px, %)',
|
||||
'placeholder': 'px, % 등 단위를 포함한 길이를 입력하세요'
|
||||
},
|
||||
{
|
||||
'path': 'styles.height',
|
||||
'type': 'text',
|
||||
'label': '높이(px, %)',
|
||||
'placeholder': 'px, % 등 단위를 포함한 높이를 입력하세요'
|
||||
},
|
||||
{
|
||||
'path': 'config.animation',
|
||||
'type': 'boolean',
|
||||
'label': 'Animation 사용'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'groupName': 'title',
|
||||
'groupTitle': '제목',
|
||||
'fields': [
|
||||
{
|
||||
'path': 'config.title.text',
|
||||
'type': 'text',
|
||||
'label': '차트 제목',
|
||||
'placeholder': '차트 제목을 입력하세요'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'groupName': 'tooltip',
|
||||
'groupTitle': 'Tooltip',
|
||||
'fields': [
|
||||
{
|
||||
'path': 'config.tooltip.show',
|
||||
'type': 'boolean',
|
||||
'label': 'Tooltip 표시'
|
||||
},
|
||||
{
|
||||
'path': 'config.tooltip.trigger',
|
||||
'type': 'select',
|
||||
'label': 'Tooltip Trigger',
|
||||
'placeholder': 'Enter the trigger type',
|
||||
'options': ['item', 'axis', 'none']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'groupName': 'legend',
|
||||
'groupTitle': 'Legend',
|
||||
'fields': [
|
||||
{
|
||||
'path': 'config.legend.show',
|
||||
'type': 'boolean',
|
||||
'label': 'Legend 표시'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'groupName': 'grid',
|
||||
'groupTitle': 'Grid',
|
||||
'fields': [
|
||||
{
|
||||
'path': 'config.grid.show',
|
||||
'type': 'boolean',
|
||||
'label': 'Grid 표시'
|
||||
},
|
||||
{
|
||||
'path': 'config.grid.left',
|
||||
'type': 'text',
|
||||
'label': 'Grid Left',
|
||||
'placeholder': '좌측 여백'
|
||||
},
|
||||
{
|
||||
'path': 'config.grid.right',
|
||||
'type': 'text',
|
||||
'label': 'Grid Right',
|
||||
'placeholder': '우측 여백'
|
||||
},
|
||||
{
|
||||
'path': 'config.grid.top',
|
||||
'type': 'text',
|
||||
'label': 'Grid Top',
|
||||
'placeholder': '위쪽 여백'
|
||||
},
|
||||
{
|
||||
'path': 'config.grid.bottom',
|
||||
'type': 'text',
|
||||
'label': 'Grid Bottom',
|
||||
'placeholder': '아래쪽 여백'
|
||||
},
|
||||
{
|
||||
'path': 'config.grid.containLabel',
|
||||
'type': 'boolean',
|
||||
'label': 'Contain Label'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'groupName': 'xAxis',
|
||||
'groupTitle': 'X축',
|
||||
'fields': [
|
||||
{
|
||||
'path': 'config.xAxis.show',
|
||||
'type': 'boolean',
|
||||
'label': 'X축 표시'
|
||||
},
|
||||
{
|
||||
'path': 'config.xAxis.type',
|
||||
'type': 'select',
|
||||
'label': 'X축 유형',
|
||||
'placeholder': 'X축 유형을 선택하세요',
|
||||
'options': ['category', 'value', 'time']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'groupName': 'yAxis',
|
||||
'groupTitle': 'Y축',
|
||||
'fields': [
|
||||
{
|
||||
'path': 'config.yAxis[0].show',
|
||||
'type': 'boolean',
|
||||
'label': 'Y축 표시'
|
||||
},
|
||||
{
|
||||
'path': 'config.yAxis[0].name',
|
||||
'type': 'text',
|
||||
'label': 'Y축 이름'
|
||||
},
|
||||
{
|
||||
'path': 'config.yAxis[0].type',
|
||||
'type': 'select',
|
||||
'label': 'Y축 유형',
|
||||
'placeholder': 'Y축 유형을 선택하세요',
|
||||
'options': ['category', 'value', 'time']
|
||||
},
|
||||
{
|
||||
'path': 'config.yAxis[0].position',
|
||||
'type': 'select',
|
||||
'label': 'Y축 위치',
|
||||
'placeholder': 'Y축 위치를 선택하세요',
|
||||
'options': ['left', 'right']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'groupName': 'toolbox',
|
||||
'groupTitle': 'Toolbox',
|
||||
'fields': [
|
||||
{
|
||||
'path': 'config.toolbox.show',
|
||||
'type': 'boolean',
|
||||
'label': 'Toolbox 표시',
|
||||
'placeholder': 'Check to show toolbox'
|
||||
},
|
||||
{
|
||||
'path': 'config.toolbox.feature.saveAsImage.show',
|
||||
'type': 'boolean',
|
||||
'label': 'Save as Image 사용'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
this.seriesConfigModel = [
|
||||
{
|
||||
'groupName': 'default',
|
||||
'groupTitle': '시리즈 정보',
|
||||
'fields': [
|
||||
{
|
||||
'path': 'seriesConfig[0].queryType',
|
||||
'type': 'select',
|
||||
'label': 'Query Type',
|
||||
'options': ['elasticsearch']
|
||||
},
|
||||
// {
|
||||
// 'path': 'seriesConfig[0].type',
|
||||
// 'type': 'select',
|
||||
// 'label': '차트 유형',
|
||||
// 'placeholder': '차트 유형을 선택하세요',
|
||||
// 'options': ['bar', 'line', 'pie']
|
||||
// },
|
||||
{
|
||||
'path': 'seriesConfig[0].index',
|
||||
'type': 'text',
|
||||
'label': 'Elasticsearch Index',
|
||||
'placeholder': 'Elasticsearch Index를 입력하세요'
|
||||
},
|
||||
{
|
||||
'path': 'seriesConfig[0].query',
|
||||
'type': 'textarea',
|
||||
'label': 'Elasticsearch Query'
|
||||
},
|
||||
{
|
||||
'path': 'seriesConfig[0].queryResult',
|
||||
'type': 'textarea',
|
||||
'label': 'Elasticsearch Query Result'
|
||||
},
|
||||
{
|
||||
'path': 'seriesConfig[0].dataPath',
|
||||
'type': 'text',
|
||||
'label': '데이터 경로',
|
||||
'placeholder': '데이터 경로를 입력하세요'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
initChartEditor() {
|
||||
|
||||
const chartEditorPopup = `
|
||||
<div class="modal fade" id="chart_editor_modal" tabindex="-1" aria-labelledby="chart_editor_modal" aria-hidden="true">
|
||||
<div class="modal-dialog modal-fullscreen modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">차트 정보</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="editor_container">
|
||||
<input type="hidden" name="type" value="" />
|
||||
<div class="left-panel default-info">
|
||||
${LayoutUtil.createForm(this.chartEditorModel)}
|
||||
</div>
|
||||
<div class="editor-right-panel">
|
||||
<div class="series-config d-flex flex-column">
|
||||
<h6>Data Source</h6>
|
||||
<div class="d-flex flex-row">
|
||||
<div class="form-floating me-2" style="flex: 1">
|
||||
<select class="form-select" name="seriesConfig[0].queryType">
|
||||
<option value="elasticsearch">Elasticsearch</option>
|
||||
</select>
|
||||
<label for="queryType">Query Type</label>
|
||||
</div>
|
||||
<div class="form-floating me-2" style="flex: 1">
|
||||
<input class="form-control" type="text" name="seriesConfig[0].index" value="" />
|
||||
<label for="index">Elasticsearch Index</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-row mt-2" style="flex: 1">
|
||||
<div style="flex: 1">
|
||||
<div class="form-floating me-2" style="height: 100%">
|
||||
<textarea class="form-control" type="text" name="seriesConfig[0].query" id="seriesConfigQuery" placeholder="Code goes here..." style="height: 400px;">
|
||||
</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex: 1">
|
||||
<div class="form-floating me-2" style="height: 100%">
|
||||
<textarea class="form-control" type="text" name="seriesConfig[0].queryResult" style="height: 400px;" readonly>
|
||||
</textarea>
|
||||
<label for="query">요청 결과</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-row mt-2" style="flex: 1">
|
||||
<div style="flex: 1">
|
||||
<div class="form-floating me-2" style="height: 100%">
|
||||
<textarea class="form-control" type="text" name="seriesConfig[0].mapperY" style="height: 400px;">
|
||||
</textarea>
|
||||
<label for="mapper">데이터 Mapper</label>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex: 1">
|
||||
<div class="form-floating me-2" style="height: 100%">
|
||||
<textarea class="form-control" type="text" name="seriesConfig[0].mapperX" style="height: 400px;">
|
||||
</textarea>
|
||||
<label for="mapper">X축 Mapper</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-row" style="height: 400px">
|
||||
<div style="flex: 1">
|
||||
<div class="form-floating me-2" style="height: 100%">
|
||||
<textarea class="form-control" type="text" id="echartOption" style="width: 100%; height: 400px;" readonly>
|
||||
</textarea>
|
||||
<label for="mapper">생성된 Option</label>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex: 1">
|
||||
<div class="chart-preview form-control" id="chart-preview" style="height: 400px;">
|
||||
chart_preview
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary btn-sm" data-bs-dismiss="modal">닫기</button>
|
||||
<button type="button" class="btn btn-primary btn-sm add-chart">추가</button>
|
||||
<button type="button" class="btn btn-primary btn-sm save-chart">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
$(this.rootContainer).parent().append(chartEditorPopup);
|
||||
|
||||
//add event to modal show
|
||||
$('#chart_editor_modal').on('shown.bs.modal', (event) => {
|
||||
let chartContainer = document.getElementById('chart-preview');
|
||||
let rect = chartContainer.getBoundingClientRect();
|
||||
let width = rect.width; // Precise width
|
||||
let height = rect.height; // Precise height
|
||||
this.chartPreview = echarts.init(document.getElementById('chart-preview'), 'dark', {
|
||||
width: width, height: height
|
||||
});
|
||||
this.drawCharts();
|
||||
});
|
||||
}
|
||||
|
||||
showChartEditor(isNew, tempChart, containerIndex, columnIndex) {
|
||||
this.targetContainerIndex = containerIndex;
|
||||
this.targetColumnIndex = columnIndex;
|
||||
this.tempChart = tempChart;
|
||||
if (isNew) {
|
||||
$('.add-chart').show();
|
||||
$('.save-chart').hide();
|
||||
} else {
|
||||
$('.add-chart').hide();
|
||||
$('.save-chart').show();
|
||||
}
|
||||
|
||||
let flatModel = LayoutUtil.flattenObject(tempChart);
|
||||
|
||||
$('#chart_editor_modal').find('input, select, textarea').each((index, element) => {
|
||||
if ($(element).attr('type') === 'checkbox') {
|
||||
$(element).prop('checked', false);
|
||||
}
|
||||
$(element).val('');
|
||||
});
|
||||
|
||||
_.forEach(flatModel, (value, key) => {
|
||||
|
||||
const inputSelector = `input[name="${key}"]`;
|
||||
const selectSelector = `select[name="${key}"]`;
|
||||
const textareaSelector = `textarea[name="${key}"]`;
|
||||
|
||||
const $input = $('#chart_editor_modal').find(inputSelector);
|
||||
const $select = $('#chart_editor_modal').find(selectSelector);
|
||||
const $textarea = $('#chart_editor_modal').find(textareaSelector);
|
||||
|
||||
if ($input.length > 0 && $input.attr('type') === 'checkbox') {
|
||||
// If it's a checkbox, update its 'checked' property
|
||||
$input.prop('checked', value);
|
||||
} else if ($input.length > 0 || $select.length > 0 || $textarea.length > 0) {
|
||||
// If any input, select, or textarea is found, update its value
|
||||
$($input.add($select).add($textarea)).val(value);
|
||||
} else {
|
||||
// If no elements are found, log to the console
|
||||
console.log(`Element not found for name: ${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
$('#chart_editor_modal').modal('show');
|
||||
console.log('chart_editor_modal show');
|
||||
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
addChart() {
|
||||
$('#chart_editor_modal').find('input, select, textarea').each((index, element) => {
|
||||
const fieldName = $(element).attr('name');
|
||||
const newValue = LayoutUtil.getFieldValue(element);
|
||||
if (fieldName !== '') {
|
||||
_.set(this.tempChart, fieldName, newValue);
|
||||
}
|
||||
});
|
||||
this.layoutManager.layoutData.children[this.targetContainerIndex].children.push(JSON.parse(JSON.stringify(this.tempChart)));
|
||||
this.tempChart = null;
|
||||
this.targetContainerIndex = -1;
|
||||
this.targetColumnIndex = -1;
|
||||
|
||||
this.layoutManager.resetLayout();
|
||||
$('#chart_editor_modal').modal('hide');
|
||||
}
|
||||
|
||||
cleanUpEvents() {
|
||||
console.log('cleanUpEvents');
|
||||
$('.add-chart').off('click');
|
||||
$('.save-chart').off('click');
|
||||
}
|
||||
|
||||
saveChart() {
|
||||
console.log('saveChart');
|
||||
|
||||
$('#chart_editor_modal').find('input, select, textarea').each((index, element) => {
|
||||
const fieldName = $(element).attr('name');
|
||||
const newValue = LayoutUtil.getFieldValue(element);
|
||||
if (fieldName !== undefined && fieldName !== '') {
|
||||
_.set(this.tempChart, fieldName, newValue);
|
||||
}
|
||||
});
|
||||
const json = JSON.stringify(this.tempChart, LayoutUtil.echartReplacer, 2);
|
||||
console.log(json);
|
||||
this.layoutManager.layoutData.children[this.targetContainerIndex].children[this.targetColumnIndex] = JSON.parse(json);
|
||||
this.targetContainerIndex = -1;
|
||||
this.targetColumnIndex = -1;
|
||||
this.layoutManager.resetLayout();
|
||||
$('#chart_editor_modal').modal('hide');
|
||||
}
|
||||
|
||||
refreshChart(chartInfo) {
|
||||
console.log('refreshChart');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let option = JSON.parse(JSON.stringify(chartInfo.config));
|
||||
option.series = [];
|
||||
|
||||
let self = this;
|
||||
Promise.all(this.tempChart.seriesConfig.map(config => this.dashboard.requestQuery(config))).then(async (results) => {
|
||||
await self.dashboard.updateChartSeries(option, results, chartInfo, (index, result) => {
|
||||
$('#chart_editor_modal').find(`textarea[name='seriesConfig[${index}].queryResult']`).val('result = ' + JSON.stringify(result, null, 2));
|
||||
});
|
||||
|
||||
document.getElementById('echartOption').value = JSON.stringify(option, null, 2);
|
||||
self.chartPreview.setOption(option);
|
||||
resolve();
|
||||
}).catch((error) => {
|
||||
console.error('Error fetching data for preview: ', error);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
drawCharts() {
|
||||
console.log('drawCharts');
|
||||
|
||||
const promises = [this.refreshChart(this.tempChart)];
|
||||
|
||||
Promise.all(promises).then(() => console.log('Chart Preview is completed')).catch(error => console.error('An error occurred:', error));
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
$('.add-chart').off('click').on('click', (event) => {
|
||||
this.addChart();
|
||||
});
|
||||
|
||||
$('.save-chart').off('click').on('click', (event) => {
|
||||
this.saveChart();
|
||||
});
|
||||
|
||||
$('#chart_editor_modal').find('input, select, textarea').each((index, element) => {
|
||||
$(element).off('change').on('change', (event) => {
|
||||
console.log('change event');
|
||||
const fieldName = $(event.currentTarget).attr('name');
|
||||
const newValue = LayoutUtil.getFieldValue(event.currentTarget);
|
||||
if (fieldName !== '') {
|
||||
_.set(this.tempChart, fieldName, newValue);
|
||||
}
|
||||
this.refreshChart(this.tempChart);
|
||||
});
|
||||
});
|
||||
|
||||
$('#chart_editor_modal').on('hidden.bs.modal', (event) => {
|
||||
if (this.chartPreview != null && this.chartPreview.dispose) {
|
||||
this.chartPreview.dispose();
|
||||
this.chartPreview = null;
|
||||
}
|
||||
|
||||
this.cleanUpEvents();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,344 +0,0 @@
|
||||
class Dashboard {
|
||||
|
||||
constructor(layoutData) {
|
||||
this.layoutData = layoutData || {};
|
||||
this.fromDateTarget = 'fromDate';
|
||||
this.toDateTarget = 'toDate';
|
||||
this.charts = [];
|
||||
this.theme = 'dark';
|
||||
this.requestUrl = '/mgmt/dashboard/search.do';
|
||||
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
const events = [{selector: '.refresh', action: this.refresh.bind(this)}];
|
||||
|
||||
for (let event of events) {
|
||||
$(document).off('click', event.selector).on('click', event.selector, event.action);
|
||||
}
|
||||
}
|
||||
|
||||
getDateTemplate(id, label) {
|
||||
return `
|
||||
<div class="input-group mb-3">
|
||||
<div class="form-floating">
|
||||
<input id="${id}Input" type="text" class="form-control" data-td-target="#${id}"/>
|
||||
<label for="${id}Input">${label}</label>
|
||||
</div>
|
||||
<span class="input-group-text" data-td-target="#${id}" data-td-toggle="datetimepicker">
|
||||
<span class="fa-solid fa-calendar"></span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init(chartAreaId) {
|
||||
console.log('Dashboard init');
|
||||
this.chartAreaId = chartAreaId;
|
||||
this.initDate();
|
||||
this.initCharts();
|
||||
this.drawCharts();
|
||||
|
||||
window.addEventListener('resize', this.resizeAllCharts.bind(this));
|
||||
}
|
||||
|
||||
initCharts() {
|
||||
console.log('initCharts');
|
||||
this.charts = [];
|
||||
if (this.layoutData.children) {
|
||||
this.layoutData.children.forEach((row) => {
|
||||
row.children.forEach((chartInfo) => {
|
||||
this.charts.push(chartInfo);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
formatDate(isoString) {
|
||||
const date = new Date(isoString);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0'); // +1 because getMonth() returns 0-11
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
formatTime(isoString) {
|
||||
const date = new Date(isoString);
|
||||
const hour = String(date.getHours()).padStart(2, '0');
|
||||
const minute = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${hour}:${minute}:00`;
|
||||
}
|
||||
|
||||
formatDateTime(isoString) {
|
||||
return this.formatDate(isoString) + 'T' + this.formatTime(isoString);
|
||||
}
|
||||
|
||||
startOfDay(isoString) {
|
||||
return this.formatDate(isoString) + 'T00:00:00';
|
||||
}
|
||||
|
||||
initDate() {
|
||||
const ko = tempusDominus.locales.ko.localization;
|
||||
const fromDateElement = document.getElementById(this.fromDateTarget);
|
||||
$(fromDateElement).data('td-target-input', 'nearest');
|
||||
$(fromDateElement).data('td-target-toggle', 'nearest');
|
||||
|
||||
const toDateElement = document.getElementById(this.toDateTarget);
|
||||
$(toDateElement).data('td-target-input', 'nearest');
|
||||
$(toDateElement).data('td-target-toggle', 'nearest');
|
||||
|
||||
$(fromDateElement).append(this.getDateTemplate(this.fromDateTarget, 'From'));
|
||||
$(toDateElement).append(this.getDateTemplate(this.toDateTarget, 'To'));
|
||||
|
||||
this.fromDate = new tempusDominus.TempusDominus(fromDateElement, {
|
||||
localization: ko, defaultDate: this.startOfDay(new Date())
|
||||
});
|
||||
this.toDate = new tempusDominus.TempusDominus(toDateElement, {
|
||||
useCurrent: false, localization: ko, defaultDate: new Date()
|
||||
});
|
||||
|
||||
fromDateElement.addEventListener(tempusDominus.Namespace.events.change, (e) => {
|
||||
this.toDate.updateOptions({
|
||||
restrictions: {
|
||||
minDate: e.detail.date
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const subscription = this.toDate.subscribe(tempusDominus.Namespace.events.change, (e) => {
|
||||
this.fromDate.updateOptions({
|
||||
restrictions: {
|
||||
maxDate: e.date
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
requestPost(url, data, hds) {
|
||||
var deferred = $.Deferred();
|
||||
|
||||
var headers = $.extend({
|
||||
'Content-Type': 'application/json; charset=utf-8'
|
||||
}, hds);
|
||||
|
||||
if (headers['Content-Type'].startsWith('application/json')) {
|
||||
data = JSON.stringify(data);
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: url, async: true, method: 'POST', data: data, headers: headers
|
||||
}).done((response) => {
|
||||
deferred.resolve(response);
|
||||
}).fail((response, textStatus, errorThrown) => {
|
||||
deferred.reject(response, textStatus, errorThrown);
|
||||
});
|
||||
return deferred.promise();
|
||||
};
|
||||
|
||||
requestQuery(seriesConfig) {
|
||||
let request = null;
|
||||
try {
|
||||
if (seriesConfig.queryType === 'elasticsearch') {
|
||||
if (seriesConfig.query && seriesConfig.index !== '') {
|
||||
let requestQuery = JSON.parse(seriesConfig.query);
|
||||
requestQuery.query = requestQuery.query || {};
|
||||
requestQuery.query.bool = requestQuery.query.bool || {};
|
||||
requestQuery.query.bool.filter = requestQuery.query.bool.filter || [];
|
||||
requestQuery.query.bool.filter.push({
|
||||
'range': {
|
||||
'@timestamp': {
|
||||
'gte': this.formatDateTime(this.fromDate.dates.picked[0]),
|
||||
'lte': this.formatDateTime(this.toDate.dates.picked[0]),
|
||||
'time_zone': 'Asia/Seoul'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
request = {
|
||||
query: JSON.stringify(requestQuery), index: seriesConfig.index
|
||||
};
|
||||
return this.requestPost(this.requestUrl, request, {});
|
||||
} else {
|
||||
console.log('query is empty');
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching data for ' + seriesConfig.contentId + ': ', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
resizeAllCharts() {
|
||||
this.charts.forEach(chartInfo => {
|
||||
let chartContainer = document.getElementById('chart_' + chartInfo.contentId);
|
||||
let rect = chartContainer.getBoundingClientRect();
|
||||
let width = rect.width; // Precise width
|
||||
let height = rect.height; // Precise height
|
||||
if (chartInfo.echart) {
|
||||
chartInfo.echart.resize({width: width, height: height});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initializeChart(chartInfo) {
|
||||
console.log('initializeChart: ' + chartInfo.contentId);
|
||||
let chartContainer = document.getElementById('chart_' + chartInfo.contentId);
|
||||
let rect = chartContainer.getBoundingClientRect();
|
||||
let width = rect.width; // Precise width
|
||||
let height = rect.height; // Precise height
|
||||
|
||||
chartInfo.echart = echarts.init(document.getElementById('chart_' + chartInfo.contentId), this.theme, {
|
||||
width: width, height: height
|
||||
});
|
||||
}
|
||||
|
||||
executeScript(result, script) {
|
||||
console.log('executeScript');
|
||||
if (script) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let resultFrame = document.getElementById('mapperScript');
|
||||
window.preRequestComplete = (result, error) => {
|
||||
if (error) {
|
||||
console.log('Error executing script: ', error);
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
};
|
||||
window.result = result;
|
||||
resultFrame.srcdoc = `<script>
|
||||
function get(obj, path, defaultValue = undefined) {
|
||||
const keys = Array.isArray(path) ? path : path.replace(/\\[(\\d+)\\]/g, '.$1').split('.');
|
||||
let result = obj;
|
||||
for (let key of keys) {
|
||||
result = result?.[key];
|
||||
if (result === undefined) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function formatDate(isoString) {
|
||||
const date = new Date(isoString);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0'); // +1 because getMonth() returns 0-11
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return \`\${year}-\${month}-\${day}\`;
|
||||
}
|
||||
|
||||
function formatTime(isoString) {
|
||||
const date = new Date(isoString);
|
||||
const hour = String(date.getHours()).padStart(2, '0');
|
||||
const minute = String(date.getMinutes()).padStart(2, '0');
|
||||
return \`\${hour}:\${minute}:00\`;
|
||||
}
|
||||
|
||||
function formatDateTime(isoString) {
|
||||
return formatDate(isoString) + 'T' + formatTime(isoString);
|
||||
}
|
||||
|
||||
function startOfDay(isoString) {
|
||||
return formatDate(isoString) + 'T00:00:00';
|
||||
}
|
||||
|
||||
window.result = window.parent.result;
|
||||
try {
|
||||
const executeFunction = ${script};
|
||||
const data = executeFunction(window.result);
|
||||
window.parent.preRequestComplete(data, null);
|
||||
} catch (error) {
|
||||
window.parent.preRequestComplete(null, error);
|
||||
}
|
||||
</script>`;
|
||||
});
|
||||
} else {
|
||||
console.log('No script to execute');
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async refresh(event) {
|
||||
console.log('refresh');
|
||||
|
||||
for (let i = 0; i < this.charts.length; i++) {
|
||||
const chartInfo = this.charts[i];
|
||||
try {
|
||||
await this.refreshChart(chartInfo);
|
||||
} catch (error) {
|
||||
console.error(`Error refreshing chart ${chartInfo.contentId}:`, error);
|
||||
}
|
||||
}
|
||||
console.log('All Charts requests are complete');
|
||||
// const promises = this.charts.map(info => this.refreshChart(info));
|
||||
// Promise.all(promises).then(() => console.log('All Charts requests are complete')).catch(error => console.error('An error occurred:', error));
|
||||
}
|
||||
|
||||
drawCharts() {
|
||||
console.log('drawCharts');
|
||||
this.charts.forEach(info => this.initializeChart(info));
|
||||
|
||||
this.refresh();
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
async updateChartSeries(option, results, chartInfo, callback) {
|
||||
console.log('updateChartSeries');
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const result = results[i];
|
||||
if (result){
|
||||
let config = chartInfo.seriesConfig[i];
|
||||
let parsedResult = JSON.parse(result);
|
||||
|
||||
if (callback) {
|
||||
callback(i, parsedResult);
|
||||
}
|
||||
try {
|
||||
console.log('start executeScript');
|
||||
const dataY = await this.executeScript(parsedResult, config.mapperY);
|
||||
|
||||
if (Array.isArray(dataY)) {
|
||||
dataY.forEach(item => {
|
||||
option.series.push(item);
|
||||
});
|
||||
} else {
|
||||
option.series.push(dataY);
|
||||
}
|
||||
const dataX = await this.executeScript(parsedResult, config.mapperX);
|
||||
option.xAxis.data = dataX;
|
||||
|
||||
console.log('stop executeScript');
|
||||
} catch (error) {
|
||||
console.error(`Error updating series for chart ${chartInfo.contentId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async refreshChart(chartInfo) {
|
||||
|
||||
try {
|
||||
console.log('refreshChart');
|
||||
|
||||
let option = JSON.parse(JSON.stringify(chartInfo.config));
|
||||
option.series = [];
|
||||
|
||||
const results = await Promise.all(chartInfo.seriesConfig.map(config => this.requestQuery(config)));
|
||||
await this.updateChartSeries(option, results, chartInfo);
|
||||
|
||||
// Clear the chart and set the new option
|
||||
chartInfo.echart.clear();
|
||||
chartInfo.echart.setOption(option);
|
||||
|
||||
console.log('Chart refreshed successfully');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching data for ' + chartInfo.contentId + ': ', error);
|
||||
throw error; // Rethrow the error if you need to catch it higher up in the call stack
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,643 +0,0 @@
|
||||
class LayoutManager {
|
||||
constructor(container, layoutData) {
|
||||
this.rootContainer = container;
|
||||
this.tempContainer = null;
|
||||
this.editContainerIndex = -1;
|
||||
this.layoutData = layoutData || {
|
||||
type: 'root',
|
||||
styles: {
|
||||
'display': 'flex',
|
||||
'flex-direction': 'column',
|
||||
'justify-content': 'start',
|
||||
'align-items': 'flex-start',
|
||||
'gap': '10px',
|
||||
'width': '100%',
|
||||
'height': '100vh',
|
||||
'position': 'relative',
|
||||
'overflow': 'auto',
|
||||
'background-color': '#100C2A'
|
||||
},
|
||||
children: []
|
||||
};
|
||||
|
||||
this.containerEditorModel = [
|
||||
{
|
||||
'groupName': 'default',
|
||||
'groupTitle': '기본',
|
||||
'fields': [
|
||||
{
|
||||
'path': 'styles.display',
|
||||
'type': 'select',
|
||||
'label': 'Display',
|
||||
'options': ['flex']
|
||||
},
|
||||
{
|
||||
'path': 'styles.flex-direction',
|
||||
'type': 'select',
|
||||
'label': 'Flex 방향',
|
||||
'options': ['row', 'column', 'row-reverse', 'column-reverse']
|
||||
},
|
||||
{
|
||||
'path': 'styles.width',
|
||||
'type': 'text',
|
||||
'label': '길이(px, %)',
|
||||
'placeholder': 'px, % 등 단위를 포함한 길이를 입력하세요'
|
||||
},
|
||||
{
|
||||
'path': 'styles.height',
|
||||
'type': 'text',
|
||||
'label': '높이(px, %)',
|
||||
'placeholder': 'px, % 등 단위를 포함한 높이를 입력하세요'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
this.dashboardEditorModel = [
|
||||
{
|
||||
'groupName': 'default',
|
||||
'groupTitle': '기본',
|
||||
'fields': [
|
||||
{
|
||||
'path': 'styles.background-color',
|
||||
'type': 'text',
|
||||
'label': '색상',
|
||||
'placeholder': '#로 시작하는 값을 입력하세요'
|
||||
},
|
||||
{
|
||||
'path': 'styles.width',
|
||||
'type': 'text',
|
||||
'label': '길이(px)',
|
||||
'placeholder': 'px 단위를 포함한 길이를 입력하세요'
|
||||
},
|
||||
{
|
||||
'path': 'styles.height',
|
||||
'type': 'text',
|
||||
'label': '높이(px)',
|
||||
'placeholder': 'px 단위를 포함한 높이를 입력하세요'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
}
|
||||
|
||||
// traverse(obj) {
|
||||
// if (obj.type === 'chart') {
|
||||
// $(`#chart_${obj.contentId}`).closest('.dashboard-chart').resizable({
|
||||
// grid: 10,
|
||||
// ghost: true,
|
||||
// stop: (event, ui) => {
|
||||
// const {width, height} = ui.size;
|
||||
// obj.styles.width = `${width}px`;
|
||||
// obj.styles.height = `${height}px`;
|
||||
// this.resetLayout();
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
//
|
||||
// if (obj.children) {
|
||||
// obj.children.forEach(child => {
|
||||
// this.traverse(child);
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
|
||||
init(Dashboard, viewerMode) {
|
||||
this.viewMode = viewerMode || false;
|
||||
this.renderRoot(this.layoutData, this.rootContainer);
|
||||
|
||||
if (Dashboard) {
|
||||
this.dashboard = new Dashboard(this.layoutData);
|
||||
this.dashboard.init('chart_area');
|
||||
}
|
||||
|
||||
if (!this.viewMode) {
|
||||
this.initContainerEditor();
|
||||
this.initDashboardEditor();
|
||||
this.chartEditor = new ChartEditor(this.rootContainer, this, this.dashboard);
|
||||
this.chartEditor.initChartEditor();
|
||||
this.bindEvents();
|
||||
}
|
||||
}
|
||||
|
||||
initContainerEditor() {
|
||||
const containerEditorPopup = `
|
||||
<div class="modal fade" id="container_editor_modal" tabindex="-1" aria-labelledby="container_editor_modal" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">컨테이너 정보</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="containerOption">
|
||||
${LayoutUtil.createForm(this.containerEditorModel)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary btn-sm" data-bs-dismiss="modal">닫기</button>
|
||||
<button type="button" class="btn btn-primary btn-sm add-container">추가</button>
|
||||
<button type="button" class="btn btn-primary btn-sm save-container">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
$(this.rootContainer).parent().append(containerEditorPopup);
|
||||
}
|
||||
|
||||
initDashboardEditor() {
|
||||
const containerEditorPopup = `
|
||||
<div class="modal fade" id="dashboard_editor_modal" tabindex="-1" aria-labelledby="dashboard_editor_modal" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">대시보드 정보</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="containerOption">
|
||||
${LayoutUtil.createForm(this.dashboardEditorModel)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary btn-sm" data-bs-dismiss="modal">닫기</button>
|
||||
<button type="button" class="btn btn-primary btn-sm save-dashboard">적용</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
$(this.rootContainer).parent().append(containerEditorPopup);
|
||||
}
|
||||
|
||||
resetLayout() {
|
||||
console.log('resetLayout');
|
||||
$(this.rootContainer).empty();
|
||||
this.renderRoot(this.layoutData, this.rootContainer);
|
||||
this.dashboard.initCharts();
|
||||
this.dashboard.layoutData = this.layoutData;
|
||||
this.dashboard.drawCharts();
|
||||
this.editContainerIndex = -1;
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
addNewChart(containerIndex) {
|
||||
this.editContainerIndex = containerIndex;
|
||||
let tempChart = {
|
||||
'contentId': '',
|
||||
'type': 'chart',
|
||||
'styles': {
|
||||
width: '100%', height: '100%'
|
||||
},
|
||||
'config': {
|
||||
'tooltip': {
|
||||
'trigger': 'axis',
|
||||
'show': true
|
||||
},
|
||||
'legend': {
|
||||
'show': true
|
||||
},
|
||||
'grid': {
|
||||
'show': true,
|
||||
'top': 0,
|
||||
'bottom': 0,
|
||||
'left': 0,
|
||||
'right': 0,
|
||||
'containLabel': true
|
||||
},
|
||||
'title': {},
|
||||
'xAxis': {
|
||||
'show': true,
|
||||
'type': 'category'
|
||||
},
|
||||
'yAxis': [
|
||||
{
|
||||
'show': true,
|
||||
'name': '',
|
||||
'type': 'value',
|
||||
'position': 'left'
|
||||
}],
|
||||
'toolbox': {
|
||||
'show': true
|
||||
}
|
||||
},
|
||||
'seriesConfig': [
|
||||
{
|
||||
'type': 'line',
|
||||
'index': '',
|
||||
'queryType': 'elasticsearch',
|
||||
'query': '',
|
||||
'mapperX': `
|
||||
function(result) {
|
||||
let data = [];
|
||||
return data;
|
||||
}
|
||||
`,
|
||||
'mapperY': `
|
||||
function(result) {
|
||||
let data = [{
|
||||
type : 'line',
|
||||
data: []
|
||||
}];
|
||||
return data;
|
||||
}
|
||||
`,
|
||||
'queryResult': ''
|
||||
}
|
||||
]
|
||||
};
|
||||
this.chartEditor.showChartEditor(true, tempChart, containerIndex);
|
||||
}
|
||||
|
||||
editChart(editContainerIndex, editColumnIndex) {
|
||||
console.log('edit-chart');
|
||||
let tempChart = this.layoutData.children[editContainerIndex].children[editColumnIndex];
|
||||
this.chartEditor.showChartEditor(false, JSON.parse(JSON.stringify(tempChart, LayoutUtil.echartReplacer)), editContainerIndex, editColumnIndex);
|
||||
}
|
||||
|
||||
renderRoot(layoutData, parent) {
|
||||
let classList = 'dashboard-root mt-2' + (!this.viewMode ? ' edit' : '');
|
||||
let children = '';
|
||||
let style = '';
|
||||
if (layoutData.styles) {
|
||||
style = Object.keys(layoutData.styles).map(key => `${key}: ${layoutData.styles[key]};`).join(' ');
|
||||
}
|
||||
console.log({style});
|
||||
if (layoutData.children) {
|
||||
layoutData.children.forEach((child) => {
|
||||
if (child.type === 'container') {
|
||||
children += this.renderContainer(child);
|
||||
} else if (child.type === 'chart') {
|
||||
children += this.renderChart(child);
|
||||
}
|
||||
});
|
||||
}
|
||||
let layoutItem = `<div class="${classList}" style="${style}"> ${children}</div>`;
|
||||
$(parent).append(layoutItem);
|
||||
// if (!this.viewMode) {
|
||||
// this.traverse(this.layoutData);
|
||||
// }
|
||||
};
|
||||
|
||||
renderChart(layoutData) {
|
||||
let style = '';
|
||||
if (layoutData.styles) {
|
||||
style = Object.keys(layoutData.styles).map(key => `${key}: ${layoutData.styles[key]};`).join(' ');
|
||||
}
|
||||
|
||||
let editButton = this.viewMode ? '' : `<div class="chart-menu-icon" data-content-id="chart_${layoutData.contentId}">
|
||||
<div class="dropdown">
|
||||
<span class="" data-bs-toggle="dropdown" aria-expanded="false">⋮</span>
|
||||
<ul class="dropdown-menu">
|
||||
<li><span class="dropdown-item edit-chart">차트 편집</span></li>
|
||||
<li><span class="dropdown-item remove-chart">차트 제거</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>`;
|
||||
return `
|
||||
<div class="dashboard-chart" data-content-id="chart_${layoutData.contentId}" style="${style}">
|
||||
${editButton}
|
||||
<div id="chart_${layoutData.contentId}" style="width: 100%; height: 100%"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderContainer(layoutData) {
|
||||
console.log('renderContainer');
|
||||
|
||||
let children = '';
|
||||
if (layoutData.children) {
|
||||
layoutData.children.forEach(child => {
|
||||
if (child.type === 'container') {
|
||||
children += this.renderContainer(child);
|
||||
} else if (child.type === 'chart') {
|
||||
children += this.renderChart(child);
|
||||
}
|
||||
});
|
||||
}
|
||||
let width = layoutData.styles.width || '100%';
|
||||
let height = layoutData.styles.height || '100%';
|
||||
|
||||
let editButton = this.viewMode ? '' : `
|
||||
<div class="container-menu-icon">
|
||||
<div class="dropdown">
|
||||
<span><i class="fa-solid fa-arrows-up-down-left-right"></i></span>
|
||||
<span class="" data-bs-toggle="dropdown" aria-expanded="false">⋮</span>
|
||||
<ul class="dropdown-menu">
|
||||
<li><span class="dropdown-item edit-container">컨테이너 편집</span></li>
|
||||
<li><span class="dropdown-item remove-container">컨테이너 제거</span></li>
|
||||
<li><span class="dropdown-item add-new-chart">차트 추가</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
let style = '';
|
||||
if (layoutData.styles) {
|
||||
style = Object.keys(layoutData.styles).map(key =>{
|
||||
if (key === 'width' || key === 'height') return `${key}: 100%;`;
|
||||
return `${key}: ${layoutData.styles[key]};`;
|
||||
} ).join(' ');
|
||||
}
|
||||
|
||||
return `<div class="dashboard-container" style="width: ${width}; height: ${height};">
|
||||
<div class="handle">
|
||||
${editButton}
|
||||
</div>
|
||||
<div class="dashboard-container-children" style="${style}">
|
||||
${children}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
addNewContainer(event) {
|
||||
this.tempContainer = {
|
||||
type: 'container',
|
||||
styles: {
|
||||
display: 'flex',
|
||||
'flex-direction': 'row',
|
||||
width: '100%',
|
||||
height: '300px'
|
||||
},
|
||||
children: []
|
||||
};
|
||||
this.showContainerEditor(true);
|
||||
}
|
||||
|
||||
editContainer(editContainerIndex) {
|
||||
console.log('edit-container: ' + editContainerIndex);
|
||||
this.tempContainer = JSON.parse(JSON.stringify(this.layoutData.children[editContainerIndex], LayoutUtil.echartReplacer));
|
||||
this.showContainerEditor(false);
|
||||
this.editContainerIndex = editContainerIndex;
|
||||
}
|
||||
|
||||
removeContainer(containerIndex) {
|
||||
console.log('edit-container: ' + containerIndex);
|
||||
this.layoutData.children.splice(containerIndex, 1);
|
||||
this.resetLayout();
|
||||
}
|
||||
|
||||
addContainer(event) {
|
||||
$('#container_editor_modal').find('input, select, textarea').each((index, element) => {
|
||||
const fieldName = $(element).attr('name');
|
||||
const newValue = LayoutUtil.getFieldValue(element);
|
||||
if (fieldName !== '') {
|
||||
_.set(this.tempContainer, fieldName, newValue);
|
||||
}
|
||||
});
|
||||
this.layoutData.children.push(JSON.parse(JSON.stringify(this.tempContainer)));
|
||||
this.tempContainer = null;
|
||||
this.resetLayout();
|
||||
$('#container_editor_modal').modal('hide');
|
||||
}
|
||||
|
||||
saveContainer(event) {
|
||||
console.log('save-container: ' + this.editContainerIndex);
|
||||
$('#container_editor_modal').find('input, select, textarea').each((index, element) => {
|
||||
const fieldName = $(element).attr('name');
|
||||
const newValue = LayoutUtil.getFieldValue(element);
|
||||
if (fieldName !== '') {
|
||||
_.set(this.tempContainer, fieldName, newValue);
|
||||
}
|
||||
});
|
||||
this.layoutData.children[this.editContainerIndex] = JSON.parse(JSON.stringify(this.tempContainer));
|
||||
this.tempContainer = null;
|
||||
this.resetLayout();
|
||||
$('#container_editor_modal').modal('hide');
|
||||
}
|
||||
|
||||
removeChart(containerIndex, columnIndex) {
|
||||
this.layoutData.children[containerIndex].children.splice(columnIndex, 1);
|
||||
this.resetLayout();
|
||||
}
|
||||
|
||||
onRowsSorted(ui) {
|
||||
let originalContainerIndex = ui.item.data('original_row_index');
|
||||
this.updateLayoutDataRows(originalContainerIndex, ui);
|
||||
}
|
||||
|
||||
onColumnsSorted(ui) {
|
||||
let containerIndex = $('.dashboard-root').children('.dashboard-container').index(ui.item.closest('.dashboard-container'));
|
||||
let originalContainerIndex = ui.item.data('original_row_index');
|
||||
if (originalContainerIndex) {
|
||||
if (originalContainerIndex === containerIndex) { // Check if the column moved from another row
|
||||
this.updateLayoutDataWithinRow(ui.item);
|
||||
} else { // The column was sorted within the same row
|
||||
this.updateLayoutDataColumns(ui.item);
|
||||
}
|
||||
this.resetLayout();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
updateLayoutDataRows(originalContainerIndex, ui) {
|
||||
let targetIndex = $('.dashboard-root').children('.dashboard-container').index(ui.item.closest('.dashboard-container'));
|
||||
|
||||
if (targetIndex !== originalContainerIndex) {
|
||||
this.layoutData.children.splice(targetIndex, 0, this.layoutData.children.splice(originalContainerIndex, 1)[0]);
|
||||
}
|
||||
console.log(this.layoutData);
|
||||
}
|
||||
|
||||
updateLayoutDataWithinRow(movedItem) {
|
||||
let oldContainerIndex = movedItem.data('original_row_index');
|
||||
let oldIndex = movedItem.data('original_column_index');
|
||||
|
||||
if (oldContainerIndex !== -1 && oldIndex !== -1) {
|
||||
let $row = movedItem.closest('.dashboard-container');
|
||||
let newIndex = $row.children('.dashboard-chart').index(movedItem);
|
||||
this.layoutData.children[oldContainerIndex].children.splice(newIndex, 0, this.layoutData.children[oldContainerIndex].children.splice(oldIndex, 1)[0]);
|
||||
}
|
||||
}
|
||||
|
||||
save() {
|
||||
const jsonString = JSON.stringify(this.layoutData, LayoutUtil.echartReplacer);
|
||||
return jsonString;
|
||||
}
|
||||
|
||||
updateLayoutDataColumns(movedItem) {
|
||||
// 새로운 행과 이전 행의 jQuery 요소를 가져옵니다
|
||||
let $newRow = movedItem.closest('.dashboard-container');
|
||||
|
||||
// 이전 행의 레이아웃 데이터에서 이동된 행과 열의 인덱스를 찾습니다
|
||||
let oldContainerIndex = movedItem.data('original_row_index');
|
||||
let movedColumnIndex = movedItem.data('original_column_index');
|
||||
|
||||
if (oldContainerIndex !== -1 && movedColumnIndex !== undefined && movedColumnIndex !== -1) {
|
||||
movedItem.data('original_row_index', -1);
|
||||
movedItem.data('original_column_index', -1);
|
||||
|
||||
// 새 행의 인덱스를 찾습니다
|
||||
let newContainerIndex = $('.dashboard-root').children('.dashboard-container').index($newRow);
|
||||
|
||||
// 이전 행에서 열 데이터를 추출합니다
|
||||
let movedColumnData = this.layoutData.children[oldContainerIndex].children.splice(movedColumnIndex, 1)[0];
|
||||
// 아이템이 드롭된 새 열 인덱스를 찾습니다
|
||||
let newColumnIndex = movedItem.closest('.dashboard-container-children').children('.dashboard-chart').index(movedItem);
|
||||
|
||||
// 이동된 열 데이터를 새 행의 열에 새 인덱스에 삽입합니다
|
||||
// 아이템이 마지막 위치로 이동된 경우 인덱스는 -1이 될 것이므로 이 경우를 처리합니다
|
||||
if (newColumnIndex === -1) {
|
||||
this.layoutData.children[newContainerIndex].children.push(movedColumnData);
|
||||
} else {
|
||||
this.layoutData.children[newContainerIndex].children.splice(newColumnIndex, 0, movedColumnData);
|
||||
}
|
||||
} else {
|
||||
console.log('called twice on update');
|
||||
}
|
||||
}
|
||||
|
||||
showContainerEditor(isNew) {
|
||||
if (isNew) {
|
||||
$('.add-container').show();
|
||||
$('.save-container').hide();
|
||||
} else {
|
||||
$('.add-container').hide();
|
||||
$('.save-container').show();
|
||||
}
|
||||
let flatModel = LayoutUtil.flattenObject(JSON.parse(JSON.stringify(this.tempContainer)));
|
||||
|
||||
$('#container_editor_modal').find('input, select, textarea').each((index, element) => {
|
||||
if ($(element).attr('type') === 'checkbox') {
|
||||
$(element).prop('checked', false);
|
||||
}
|
||||
$(element).val('');
|
||||
});
|
||||
|
||||
_.forEach(flatModel, (value, key) => {
|
||||
if ($('#container_editor_modal').find(`input[name="${key}"]`).attr('type') === 'checkbox') {
|
||||
$('#container_editor_modal').find(`input[name="${key}"]`).prop('checked', value);
|
||||
} else {
|
||||
$('#container_editor_modal').find(`input[name="${key}"], select[name="${key}"], textarea[name="${key}"]`).val(value);
|
||||
}
|
||||
});
|
||||
|
||||
$('#container_editor_modal').modal('show');
|
||||
}
|
||||
|
||||
resize() {
|
||||
console.log('resize');
|
||||
this.dashboard.resizeAllCharts();
|
||||
}
|
||||
|
||||
editDashboard(event) {
|
||||
this.showDashboardEditor();
|
||||
}
|
||||
|
||||
showDashboardEditor() {
|
||||
let flatModel = LayoutUtil.flattenObject(JSON.parse(JSON.stringify(this.layoutData, LayoutUtil.echartReplacer)));
|
||||
$('#dashboard_editor_modal').find('input, select, textarea').each((index, element) => {
|
||||
if ($(element).attr('type') === 'checkbox') {
|
||||
$(element).prop('checked', false);
|
||||
}
|
||||
$(element).val('');
|
||||
});
|
||||
|
||||
_.forEach(flatModel, (value, key) => {
|
||||
if ($('#dashboard_editor_modal').find(`input[name="${key}"]`).attr('type') === 'checkbox') {
|
||||
$('#dashboard_editor_modal').find(`input[name="${key}"]`).prop('checked', value);
|
||||
} else {
|
||||
$('#dashboard_editor_modal').find(`input[name="${key}"], select[name="${key}"], textarea[name="${key}"]`).val(value);
|
||||
}
|
||||
});
|
||||
|
||||
$('#dashboard_editor_modal').modal('show');
|
||||
}
|
||||
|
||||
saveDashboard(event) {
|
||||
$('#dashboard_editor_modal').find('input, select, textarea').each((index, element) => {
|
||||
const fieldName = $(element).attr('name');
|
||||
const newValue = LayoutUtil.getFieldValue(element);
|
||||
if (fieldName !== '') {
|
||||
_.set(this.layoutData, fieldName, newValue);
|
||||
}
|
||||
});
|
||||
console.log(this.layoutData);
|
||||
this.resetLayout();
|
||||
$('#dashboard_editor_modal').modal('hide');
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
$('.add-new-container').off('click').on('click', (event) => {
|
||||
this.addNewContainer(event);
|
||||
});
|
||||
|
||||
$('.edit-dashboard').off('click').on('click', (event) => {
|
||||
this.editDashboard(event);
|
||||
});
|
||||
|
||||
$('.save-dashboard').off('click').on('click', (event) => {
|
||||
this.saveDashboard(event);
|
||||
});
|
||||
|
||||
$('.remove-container').off('click').on('click', (event) => {
|
||||
let containerIndex = $('.dashboard-root').children('.dashboard-container').index(event.target.closest('.dashboard-container'));
|
||||
this.removeContainer(containerIndex);
|
||||
});
|
||||
|
||||
$('.add-container').off('click').on('click', (event) => {
|
||||
this.addContainer(event);
|
||||
});
|
||||
|
||||
$('.save-container').off('click').on('click', (event) => {
|
||||
this.saveContainer(event);
|
||||
});
|
||||
|
||||
$('.edit-container').off('click').on('click', (event) => {
|
||||
let editContainerIndex = $('.dashboard-root').children('.dashboard-container').index(event.target.closest('.dashboard-container'));
|
||||
this.editContainer(editContainerIndex);
|
||||
});
|
||||
|
||||
$('.edit-chart').off('click').on('click', (event) => {
|
||||
let editContainerIndex = $('.dashboard-root').children('.dashboard-container').index(event.target.closest('.dashboard-container'));
|
||||
let column = event.target.closest('.dashboard-chart');
|
||||
let editColumnIndex = $(event.target.closest('.dashboard-container-children')).children('.dashboard-chart').index(column);
|
||||
this.editChart(editContainerIndex, editColumnIndex);
|
||||
});
|
||||
|
||||
$('.add-new-chart').off('click').on('click', (event) => {
|
||||
let containerIndex = $('.dashboard-root').children('.dashboard-container').index(event.target.closest('.dashboard-container'));
|
||||
this.addNewChart(containerIndex);
|
||||
});
|
||||
|
||||
$('.remove-chart').off('click').on('click', (event) => {
|
||||
let column = event.target.closest('.dashboard-chart');
|
||||
let containerIndex = $('.dashboard-root').children('.dashboard-container').index(event.target.closest('.dashboard-container'));
|
||||
let columnIndex = $(event.target.closest('.dashboard-container-children')).children('.dashboard-chart').index(column);
|
||||
this.removeChart(containerIndex, columnIndex);
|
||||
});
|
||||
|
||||
if ($('.dashboard-root').hasClass('ui-sortable')) {
|
||||
$('.dashboard-root').sortable('destroy');
|
||||
}
|
||||
|
||||
$('.dashboard-root').sortable({
|
||||
handle: '.handle',
|
||||
items: '.dashboard-container',
|
||||
placeholder: 'ui-state-highlight',
|
||||
start: (event, ui) => {
|
||||
let originalRowIdx = $('.dashboard-root').children('.dashboard-container').index(ui.item.closest('.dashboard-container'));
|
||||
ui.item.data('original_row_index', originalRowIdx);
|
||||
}, update: (event, ui) => {
|
||||
this.onRowsSorted(ui);
|
||||
}
|
||||
});
|
||||
|
||||
if ($('.dashboard-container').hasClass('ui-sortable')) {
|
||||
$('.dashboard-container').sortable('destroy');
|
||||
}
|
||||
$('.dashboard-container-children').sortable({
|
||||
// handle: ".handle",
|
||||
placeholder: 'ui-state-highlight',
|
||||
items: '.dashboard-chart',
|
||||
connectWith: '.dashboard-container-children',
|
||||
update: (event, ui) => {
|
||||
this.onColumnsSorted(ui);
|
||||
},
|
||||
start: (event, ui) => {
|
||||
let originalRowIdx = $('.dashboard-root').children('.dashboard-container').index(ui.item.closest('.dashboard-container'));
|
||||
let originalColumnIdx = ui.item.closest('.dashboard-container-children').children('.dashboard-chart').index(ui.item);
|
||||
ui.item.data('original_row_index', originalRowIdx);
|
||||
ui.item.data('original_column_index', originalColumnIdx);
|
||||
}
|
||||
}).disableSelection();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
* - 기선택: window.API_SELECTOR_SELECTED / 목록 URL: window.API_SELECTOR_LIST_URL (fragment 인라인 주입)
|
||||
* - "이전" 버튼: 호출 페이지의 #btnPrevStep (없으면 스킵)
|
||||
* - 카트/모달: fragment `apiSelectorPopups` 를 pagePopups 슬롯에서 호출(body 직속)
|
||||
* - 페이징: #apiPagination (PAGE_SIZE 건/페이지) — 카테고리/검색은 재조회 없이 클라이언트에서 처리
|
||||
* - 페이징: #apiPagination (window.API_SELECTOR_PAGE_SIZE 건/페이지, 기본 15) — 카테고리/검색은 클라이언트에서 처리
|
||||
*
|
||||
* design(figma s2) 인라인 스크립트 대비 패치 4건:
|
||||
* 1) 모달 열 때마다 updateModalList() 재빌드 — 세션 복원 직후(카드 렌더 전) 빈 모달 방지
|
||||
@@ -22,7 +22,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
return; // 모듈 미사용 페이지
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
const configuredPageSize = Number(globalThis.API_SELECTOR_PAGE_SIZE);
|
||||
const PAGE_SIZE = Number.isInteger(configuredPageSize) && configuredPageSize > 0 ? configuredPageSize : 15;
|
||||
|
||||
// DOM Elements
|
||||
const searchInput = document.getElementById('apiSearch');
|
||||
|
||||
@@ -160,10 +160,14 @@
|
||||
|
||||
const cards = visible.map(function (incident) {
|
||||
const timeline = (incident.recentTimeline || []).map(function (entry) {
|
||||
// 지난 이슈 이력과 같은 기준: 진행 상태(labelKo)를 상태 색으로 구분해 보여준다.
|
||||
const state = entry.stateAfter || 'NONE';
|
||||
const label = entry.labelKo || '진행 상황';
|
||||
const who = entry.authorType === 'SYSTEM' ? 'SYSTEM' : '운영자';
|
||||
const whoClass = entry.authorType === 'SYSTEM' ? ' is-system' : '';
|
||||
return '<div class="as-tl-row">'
|
||||
+ '<span class="as-tl-time">' + escapeHtml(formatTime(entry.eventAt)) + '</span>'
|
||||
+ '<span class="as-tl-state state-' + escapeHtml(state) + '">' + escapeHtml(label) + '</span>'
|
||||
+ '<span class="as-tl-who' + whoClass + '">' + escapeHtml(who) + '</span>'
|
||||
+ '<span>' + escapeHtml(entry.body) + '</span>'
|
||||
+ '</div>';
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,22 @@
|
||||
Copyright (c) JS Foundation and other contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person
|
||||
obtaining a copy of this software and associated documentation
|
||||
files (the "Software"), to deal in the Software without
|
||||
restriction, including without limitation the rights to use,
|
||||
copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
+2
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,605 @@
|
||||
/**
|
||||
* 비밀번호 전송암호화 (RSA-OAEP + AES-GCM 하이브리드).
|
||||
*
|
||||
* 브라우저 개발자도구 Network 탭에 비밀번호가 평문으로 보이는 것을 막는다.
|
||||
* 서버 공개키로 AES-256-GCM 키를 감싸고, 비밀번호는 그 AES 키로 암호화해 아래 봉투로 보낸다.
|
||||
*
|
||||
* ENC1.<keyId>.<b64url(RSA-OAEP(AES키))>.<b64url(iv 12B)>.<b64url(암호문+태그)>
|
||||
*
|
||||
* 서버(PasswordDecryptFilter)가 파라미터 값을 평문으로 되돌리므로 컨트롤러는 아무것도 몰라도 된다.
|
||||
*
|
||||
* 암호화 엔진은 두 가지이며 봉투 형식은 동일하다.
|
||||
* - subtle : secure context(HTTPS·localhost)에서 브라우저 Web Crypto 사용. 추가 다운로드 없음.
|
||||
* - forge : 원격 오리진 HTTP 처럼 crypto.subtle 이 없는 환경에서 순수 JS 구현 사용.
|
||||
* lib/forge-crypto.min.js 를 그때만 동적으로 내려받는다
|
||||
* (window.__PASSWORD_CRYPTO__.softwareFallback 로 끌 수 있다).
|
||||
*
|
||||
* 폴백: 설정이 꺼져 있거나, 두 엔진 모두 쓸 수 없거나, 키 조회·암호화 중 오류가 나면 아무 일도 하지 않고
|
||||
* 평문 그대로 전송한다. 즉 이 모듈은 절대 화면 흐름을 막지 않는다. 서버 정책이 ENFORCE 면 그 평문이
|
||||
* 거부되어 인증 실패로 이어진다.
|
||||
*
|
||||
* 주의: 이 조치는 XSS 방어도 MITM 방어도 아니다. 스크립트가 주입되면 입력창에서 직접 값을 가져갈 수 있고,
|
||||
* HTTP 구간이면 중간자가 이 스크립트 자체를 바꿔치기할 수 있다. 그래서 HTTP 접속에는 경고 팝업을 띄운다.
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var cfg = global.__PASSWORD_CRYPTO__ || {};
|
||||
var PREFIX = 'ENC1.';
|
||||
|
||||
/** 봉투를 만들 수 있는 환경에서 전송로 경고를 세션당 한 번만 띄우기 위한 sessionStorage 키 */
|
||||
var WARN_KEY = 'portal.passwordCrypto.insecureWarned';
|
||||
|
||||
// ---------- 엔진 판정 ----------
|
||||
|
||||
/**
|
||||
* Web Crypto 사용 가능 여부. 브라우저는 secure context 가 아니면 crypto.subtle 자체를 노출하지 않는다.
|
||||
* 반면 crypto.getRandomValues 는 비 secure context 에서도 쓸 수 있어 forge 경로의 난수원으로 쓴다.
|
||||
*/
|
||||
function hasSubtle() {
|
||||
return !!(global.isSecureContext && global.crypto && global.crypto.subtle);
|
||||
}
|
||||
|
||||
function hasForge() {
|
||||
return !!(cfg.softwareFallback && cfg.forgeUrl && global.document);
|
||||
}
|
||||
|
||||
/** 설정·브라우저 조건상 봉투를 만들 수 있는가. 실제 forge 스크립트 로드는 사용 시점에 한다. */
|
||||
function available() {
|
||||
return !!(cfg.enabled
|
||||
&& cfg.keyUrl
|
||||
&& global.crypto
|
||||
&& global.crypto.getRandomValues
|
||||
&& global.fetch
|
||||
&& global.Promise
|
||||
&& (hasSubtle() || hasForge()));
|
||||
}
|
||||
|
||||
// ---------- 인코딩 유틸 ----------
|
||||
|
||||
function bytesToBinary(bytes) {
|
||||
var binary = '';
|
||||
for (var i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return binary;
|
||||
}
|
||||
|
||||
function binaryToBase64Url(binary) {
|
||||
return global.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function toBase64Url(buffer) {
|
||||
return binaryToBase64Url(bytesToBinary(new Uint8Array(buffer)));
|
||||
}
|
||||
|
||||
function base64ToBytes(base64) {
|
||||
var binary = global.atob(base64);
|
||||
var bytes = new Uint8Array(binary.length);
|
||||
for (var i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** forge 는 바이트를 binary string 으로 다룬다. 난수는 항상 브라우저 CSPRNG 에서 받는다. */
|
||||
function randomBinary(length) {
|
||||
return bytesToBinary(global.crypto.getRandomValues(new Uint8Array(length)));
|
||||
}
|
||||
|
||||
// ---------- 엔진 구현 ----------
|
||||
|
||||
/**
|
||||
* 엔진 계약:
|
||||
* importKey(base64Spki) -> Promise<publicKey>
|
||||
* seal(publicKey, keyId, names, values) -> Promise<{이름: 봉투}>
|
||||
* 두 엔진의 봉투 형식·알고리즘 파라미터는 동일해야 한다. 서버(PasswordEnvelopeCodec)는
|
||||
* RSA-OAEP 의 MGF1 해시까지 SHA-256 으로 고정돼 있으므로 forge 쪽도 mgf1 을 명시해야 한다.
|
||||
*/
|
||||
var subtleEngine = {
|
||||
name: 'subtle',
|
||||
importKey: function (base64Spki) {
|
||||
return global.crypto.subtle.importKey(
|
||||
'spki',
|
||||
base64ToBytes(base64Spki),
|
||||
{ name: 'RSA-OAEP', hash: 'SHA-256' },
|
||||
false,
|
||||
['encrypt']
|
||||
);
|
||||
},
|
||||
seal: function (publicKey, keyId, names, values) {
|
||||
var subtle = global.crypto.subtle;
|
||||
var context = {};
|
||||
return subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt'])
|
||||
.then(function (aesKey) {
|
||||
context.aesKey = aesKey;
|
||||
return subtle.exportKey('raw', aesKey);
|
||||
})
|
||||
.then(function (rawAesKey) {
|
||||
return subtle.encrypt({ name: 'RSA-OAEP' }, publicKey, rawAesKey);
|
||||
})
|
||||
.then(function (wrapped) {
|
||||
var wrappedKey = toBase64Url(wrapped);
|
||||
return global.Promise.all(names.map(function (name) {
|
||||
var iv = global.crypto.getRandomValues(new Uint8Array(12));
|
||||
return subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: iv, tagLength: 128 },
|
||||
context.aesKey,
|
||||
new TextEncoder().encode(values[name])
|
||||
).then(function (cipherText) {
|
||||
return {
|
||||
name: name,
|
||||
envelope: PREFIX + keyId + '.' + wrappedKey
|
||||
+ '.' + toBase64Url(iv) + '.' + toBase64Url(cipherText)
|
||||
};
|
||||
});
|
||||
}));
|
||||
})
|
||||
.then(collectEnvelopes);
|
||||
}
|
||||
};
|
||||
|
||||
var forgeEngine = {
|
||||
name: 'forge',
|
||||
importKey: function (base64Spki) {
|
||||
return loadForge().then(function (forge) {
|
||||
var der = forge.util.createBuffer(global.atob(base64Spki));
|
||||
return forge.pki.publicKeyFromAsn1(forge.asn1.fromDer(der));
|
||||
});
|
||||
},
|
||||
seal: function (publicKey, keyId, names, values) {
|
||||
return loadForge().then(function (forge) {
|
||||
var aesKey = randomBinary(32);
|
||||
// forge 의 OAEP 시드도 브라우저 CSPRNG 로 준다. forge 내장 PRNG 를 쓰지 않기 위함이다.
|
||||
var wrappedKey = binaryToBase64Url(publicKey.encrypt(aesKey, 'RSA-OAEP', {
|
||||
md: forge.md.sha256.create(),
|
||||
mgf1: { md: forge.md.sha256.create() },
|
||||
seed: randomBinary(32)
|
||||
}));
|
||||
return names.map(function (name) {
|
||||
var iv = randomBinary(12);
|
||||
var cipher = forge.cipher.createCipher('AES-GCM', aesKey);
|
||||
cipher.start({ iv: iv, tagLength: 128 });
|
||||
cipher.update(forge.util.createBuffer(forge.util.encodeUtf8(values[name])));
|
||||
cipher.finish();
|
||||
// 서버는 Java 관례대로 "암호문 + 태그" 가 이어 붙은 형태를 기대한다.
|
||||
var payload = cipher.output.getBytes() + cipher.mode.tag.getBytes();
|
||||
return {
|
||||
name: name,
|
||||
envelope: PREFIX + keyId + '.' + wrappedKey
|
||||
+ '.' + binaryToBase64Url(iv) + '.' + binaryToBase64Url(payload)
|
||||
};
|
||||
});
|
||||
}).then(collectEnvelopes);
|
||||
}
|
||||
};
|
||||
|
||||
function collectEnvelopes(results) {
|
||||
var out = {};
|
||||
results.forEach(function (result) {
|
||||
out[result.name] = result.envelope;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 현재 환경에서 쓸 엔진. 페이지 수명 동안 바뀌지 않는다. */
|
||||
function engine() {
|
||||
if (hasSubtle()) {
|
||||
return subtleEngine;
|
||||
}
|
||||
return hasForge() ? forgeEngine : null;
|
||||
}
|
||||
|
||||
// ---------- forge 동적 로드 ----------
|
||||
|
||||
var forgeLoading = null;
|
||||
|
||||
/**
|
||||
* forge 번들은 crypto.subtle 이 없을 때만 내려받는다. HTTPS 운영 환경에서는 전송 바이트가 0 이다.
|
||||
* 한 번 시작한 로드는 재사용하고, 실패하면 호출자가 평문 폴백으로 처리한다.
|
||||
*/
|
||||
function loadForge() {
|
||||
if (global.forge && global.forge.pki && global.forge.cipher) {
|
||||
return global.Promise.resolve(global.forge);
|
||||
}
|
||||
if (forgeLoading) {
|
||||
return forgeLoading;
|
||||
}
|
||||
forgeLoading = new global.Promise(function (resolve, reject) {
|
||||
var script = global.document.createElement('script');
|
||||
script.src = cfg.forgeUrl;
|
||||
script.async = true;
|
||||
script.onload = function () {
|
||||
if (global.forge && global.forge.pki && global.forge.cipher) {
|
||||
resolve(global.forge);
|
||||
} else {
|
||||
reject(new Error('forge 번들이 전역을 노출하지 않음'));
|
||||
}
|
||||
};
|
||||
script.onerror = function () {
|
||||
reject(new Error('forge 번들 로드 실패: ' + cfg.forgeUrl));
|
||||
};
|
||||
global.document.head.appendChild(script);
|
||||
});
|
||||
return forgeLoading;
|
||||
}
|
||||
|
||||
// ---------- 키 조회 ----------
|
||||
|
||||
/** 재사용 가능한 스코프(SESSION/SERVER)에서만 채워진다. { keyId, publicKey, expiresAt } */
|
||||
var cachedKey = null;
|
||||
|
||||
/**
|
||||
* 서버에서 공개키를 받아 현재 엔진 형식으로 import 한다.
|
||||
*
|
||||
* key-scope 가 REQUEST 면 발급된 키가 1회용이라 매번 새로 받는다. SESSION/SERVER 면 만료 전까지
|
||||
* 캐시해 재사용한다 — 비밀번호 검증용 ajax(입력 중 호출)가 호출마다 서버에서 RSA 키쌍을
|
||||
* 생성하게 만들지 않기 위함이다.
|
||||
*/
|
||||
function loadKey(currentEngine) {
|
||||
if (cachedKey && cachedKey.expiresAt > Date.now()) {
|
||||
return global.Promise.resolve(cachedKey);
|
||||
}
|
||||
return global.fetch(cfg.keyUrl, {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
}).then(function (response) {
|
||||
if (!response.ok) {
|
||||
throw new Error('공개키 조회 실패: HTTP ' + response.status);
|
||||
}
|
||||
return response.json();
|
||||
}).then(function (data) {
|
||||
if (!data || !data.enabled || !data.keyId || !data.publicKey) {
|
||||
throw new Error('전송암호화 비활성 상태의 응답');
|
||||
}
|
||||
return currentEngine.importKey(data.publicKey).then(function (publicKey) {
|
||||
var key = { keyId: data.keyId, publicKey: publicKey };
|
||||
if (data.keyScope && data.keyScope !== 'REQUEST') {
|
||||
// 만료 30초 전에는 버려서 경계에서 실패하지 않게 한다.
|
||||
var ttlMs = Math.max(0, (data.expiresIn || 0) - 30) * 1000;
|
||||
if (ttlMs > 0) {
|
||||
key.expiresAt = Date.now() + ttlMs;
|
||||
cachedKey = key;
|
||||
}
|
||||
}
|
||||
return key;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 암호화 ----------
|
||||
|
||||
/**
|
||||
* 값 여러 개를 한 번에 암호화한다. AES 키와 RSA 랩은 1회만 하고 IV 만 값마다 새로 만든다.
|
||||
* @param {Object} values 이름 → 평문
|
||||
* @returns {Promise<Object>} 이름 → 봉투 문자열
|
||||
*/
|
||||
function encryptValues(values) {
|
||||
var names = Object.keys(values || {}).filter(function (name) {
|
||||
var v = values[name];
|
||||
return typeof v === 'string' && v.length > 0 && v.indexOf(PREFIX) !== 0;
|
||||
});
|
||||
var currentEngine = available() ? engine() : null;
|
||||
if (!currentEngine || names.length === 0) {
|
||||
return global.Promise.resolve(values || {});
|
||||
}
|
||||
|
||||
return loadKey(currentEngine).then(function (key) {
|
||||
return currentEngine.seal(key.publicKey, key.keyId, names, values);
|
||||
}).then(function (envelopes) {
|
||||
var out = {};
|
||||
Object.keys(values).forEach(function (name) { out[name] = values[name]; });
|
||||
Object.keys(envelopes).forEach(function (name) { out[name] = envelopes[name]; });
|
||||
return out;
|
||||
}).catch(function (error) {
|
||||
// 암호화 실패는 화면을 막지 않는다. 서버 정책이 ENFORCE 가 아니면 평문으로 처리된다.
|
||||
if (global.console && global.console.warn) {
|
||||
global.console.warn('[password-crypto] 평문으로 폴백:', error && error.message);
|
||||
}
|
||||
return values;
|
||||
});
|
||||
}
|
||||
|
||||
/** 원본 입력에서 name 을 잠시 떼어둔 동안 그 이름을 보관하는 표시용 속성 */
|
||||
var NAME_HOLDER = 'data-password-crypto-name';
|
||||
|
||||
/**
|
||||
* 입력 요소에서 실제 <input> 하나를 고른다.
|
||||
* 같은 name 이 둘 이상이면 form.elements[name] 이 RadioNodeList 를 돌려주므로,
|
||||
* 우리가 붙인 hidden 이 아닌 실제 입력창을 골라야 한다.
|
||||
*/
|
||||
function resolveInput(el) {
|
||||
if (!el) {
|
||||
return null;
|
||||
}
|
||||
if (el.tagName) {
|
||||
return el;
|
||||
}
|
||||
for (var i = 0; i < el.length; i++) {
|
||||
var candidate = el[i];
|
||||
if (candidate && candidate.tagName && candidate.type !== 'hidden') {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 원본 입력의 value 는 그대로 두고, name 만 같은 이름의 hidden 으로 옮겨 봉투를 싣는다.
|
||||
* @returns {Function} 원상복구 함수
|
||||
*/
|
||||
function swapToEnvelopeField(form, input, name, envelope) {
|
||||
var hidden = (form.ownerDocument || global.document).createElement('input');
|
||||
hidden.type = 'hidden';
|
||||
hidden.name = name;
|
||||
hidden.value = envelope;
|
||||
hidden.setAttribute(NAME_HOLDER, name);
|
||||
|
||||
input.setAttribute(NAME_HOLDER, name);
|
||||
input.removeAttribute('name');
|
||||
form.appendChild(hidden);
|
||||
|
||||
return function restore() {
|
||||
if (hidden.parentNode) {
|
||||
hidden.parentNode.removeChild(hidden);
|
||||
}
|
||||
if (input.getAttribute(NAME_HOLDER) === name) {
|
||||
input.setAttribute('name', name);
|
||||
input.removeAttribute(NAME_HOLDER);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 제출 직후 원상복구한다. 폼 데이터 직렬화는 form.submit()/requestSubmit() 호출 시점에
|
||||
* 동기로 끝나므로, 다음 매크로태스크에서 되돌려도 봉투는 이미 전송된 뒤다.
|
||||
* 뒤로가기(bfcache) 복원 시에도 name 이 떨어진 채 남지 않도록 pageshow 에서 한 번 더 처리한다.
|
||||
*/
|
||||
function scheduleRestore(restores) {
|
||||
if (restores.length === 0) {
|
||||
return;
|
||||
}
|
||||
var done = false;
|
||||
var run = function () {
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
done = true;
|
||||
restores.forEach(function (restore) { restore(); });
|
||||
global.removeEventListener('pageshow', run);
|
||||
};
|
||||
global.addEventListener('pageshow', run);
|
||||
global.setTimeout(run, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 폼의 지정 필드를 봉투로 전송한다. 실패해도 reject 하지 않고 평문을 남긴다.
|
||||
*
|
||||
* 입력창(<input type="password">)의 value 는 절대 건드리지 않는다. 브라우저 비밀번호 관리자는
|
||||
* 제출 시점의 입력값을 그대로 저장하므로 봉투로 덮어쓰면 봉투 문자열이 비밀번호로 저장되고,
|
||||
* 다음 자동완성 로그인이 깨진다. 대신 원본 입력의 name 을 잠시 떼고 같은 name 의 hidden 에
|
||||
* 봉투를 실어 보낸 뒤 제출 직후 되돌린다.
|
||||
*
|
||||
* @param {HTMLFormElement} form
|
||||
* @param {string[]} fieldNames
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function encryptForm(form, fieldNames) {
|
||||
if (!form || !fieldNames || fieldNames.length === 0 || !available()) {
|
||||
return global.Promise.resolve();
|
||||
}
|
||||
var targets = {};
|
||||
var elements = {};
|
||||
fieldNames.forEach(function (name) {
|
||||
var el = resolveInput(form.elements ? form.elements[name] : null);
|
||||
if (el && typeof el.value === 'string' && el.value.length > 0) {
|
||||
elements[name] = el;
|
||||
targets[name] = el.value;
|
||||
}
|
||||
});
|
||||
if (Object.keys(targets).length === 0) {
|
||||
return global.Promise.resolve();
|
||||
}
|
||||
return encryptValues(targets).then(function (encrypted) {
|
||||
var restores = [];
|
||||
Object.keys(elements).forEach(function (name) {
|
||||
// 폴백으로 평문이 그대로 돌아온 경우엔 아무것도 바꾸지 않는다.
|
||||
if (encrypted[name] && encrypted[name] !== targets[name]) {
|
||||
restores.push(swapToEnvelopeField(form, elements[name], name, encrypted[name]));
|
||||
}
|
||||
});
|
||||
scheduleRestore(restores);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 전송로 경고 ----------
|
||||
|
||||
/**
|
||||
* 팝업을 띄운다. custom-popups.js 는 `const customPopups`(전역 렉시컬)로 노출돼
|
||||
* window 프로퍼티가 아니므로 식별자로 직접 확인한다. 팝업 프래그먼트가 없는 화면에서는
|
||||
* 브라우저 기본 alert 로 떨어진다.
|
||||
*/
|
||||
function showDialog(message) {
|
||||
try {
|
||||
if (typeof customPopups !== 'undefined'
|
||||
&& customPopups && typeof customPopups.showAlert === 'function'
|
||||
&& global.document.getElementById('customAlert')) {
|
||||
customPopups.showAlert(message);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// customPopups 미정의 등 — 아래 기본 alert 로 떨어진다.
|
||||
}
|
||||
global.alert(message.replace(/<br\s*\/?>/gi, '\n'));
|
||||
}
|
||||
|
||||
/** 알림 팝업이 지금 떠 있는가. custom-popups.js 는 #customAlert 를 show()/hide() 로 토글한다. */
|
||||
function isAlertOpen() {
|
||||
var el = global.document.getElementById('customAlert');
|
||||
return !!(el && el.style.display !== 'none');
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미 떠 있는 알림이 닫힌 뒤에 띄운다.
|
||||
*
|
||||
* 알림 팝업은 페이지 전체가 #customAlert 하나를 돌려쓰고 showAlert() 는 그 안의 메시지를 덮어쓴다.
|
||||
* 우리 경고는 load 시점이라 화면 자신의 메시지(DOMContentLoaded 에서 뜨는 로그인 실패 안내 등)보다
|
||||
* 늦게 실행되므로, 그냥 부르면 그 메시지를 지워버린다. 먼저 뜬 쪽을 존중하고 뒤에 선다.
|
||||
*
|
||||
* hideAlert() 가 300ms 애니메이션 뒤에 display 를 내리므로 폴링으로 확인한다.
|
||||
* 사용자가 계속 닫지 않으면 1분 뒤 포기한다(경고를 못 봐도 화면을 방해하지는 않는다).
|
||||
*/
|
||||
function showDialogQueued(message) {
|
||||
if (!isAlertOpen()) {
|
||||
showDialog(message);
|
||||
return;
|
||||
}
|
||||
var waited = 0;
|
||||
var timer = global.setInterval(function () {
|
||||
waited += 200;
|
||||
if (!isAlertOpen()) {
|
||||
global.clearInterval(timer);
|
||||
showDialog(message);
|
||||
} else if (waited >= 60000) {
|
||||
global.clearInterval(timer);
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTPS 가 아닌 연결에서 비밀번호를 입력하려는 화면에 경고를 띄운다.
|
||||
*
|
||||
* 정책이 NONE 이면 아무것도 하지 않는다. 문구와 노출 빈도는 이 브라우저가 봉투를 만들 수 있는지로 갈린다.
|
||||
*
|
||||
* <ul>
|
||||
* <li>봉투를 만들 수 있음 - 정보성 경고다. 값은 암호화되어 나가고, 남는 위험은 중간자가 이 스크립트
|
||||
* 자체를 바꿔치기하는 경우뿐이다. 매번 띄우면 방해만 되므로 <b>세션당 1회</b>.</li>
|
||||
* <li>봉투를 만들 수 없음 - 비밀번호가 평문으로 나가거나(permissive), 서버가 거부해 로그인 자체가
|
||||
* 안 된다(enforce). 사용자가 놓치면 안 되는 상태이므로 <b>화면을 열 때마다</b>.</li>
|
||||
* </ul>
|
||||
*/
|
||||
function warnInsecureTransport() {
|
||||
if (!cfg.enabled || !cfg.policy || cfg.policy === 'NONE') {
|
||||
return;
|
||||
}
|
||||
if (!global.location || global.location.protocol === 'https:') {
|
||||
return;
|
||||
}
|
||||
if (!global.document.querySelector('input[type="password"]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
var encryptable = available();
|
||||
if (encryptable && !markWarnedOnce()) {
|
||||
return;
|
||||
}
|
||||
|
||||
var message;
|
||||
if (encryptable) {
|
||||
message = '보안 경고<br>현재 <strong>HTTPS 가 아닌 연결(HTTP)</strong>로 접속했습니다.<br>'
|
||||
+ '비밀번호는 전송 전에 암호화되지만, 중간자 공격까지 막지는 못합니다.<br>'
|
||||
+ '운영 환경에서는 HTTPS 로 접속하세요.';
|
||||
} else if (cfg.policy === 'ENFORCE') {
|
||||
message = '보안 경고<br>현재 <strong>HTTPS 가 아닌 연결(HTTP)</strong>이고, 이 브라우저에서는 '
|
||||
+ '비밀번호 암호화를 사용할 수 없습니다.<br>'
|
||||
+ '서버가 암호화되지 않은 비밀번호를 거부하므로 로그인할 수 없습니다.<br>'
|
||||
+ '관리자에게 문의하세요.';
|
||||
} else {
|
||||
message = '보안 경고<br>현재 <strong>HTTPS 가 아닌 연결(HTTP)</strong>이고, 이 브라우저에서는 '
|
||||
+ '비밀번호 암호화를 사용할 수 없습니다.<br>'
|
||||
+ '비밀번호가 <strong>암호화되지 않은 상태로</strong> 전송됩니다.';
|
||||
}
|
||||
showDialogQueued(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이번 탭 세션에서 아직 경고를 안 띄웠으면 표시를 남기고 true 를 준다.
|
||||
* 프라이빗 모드 등으로 sessionStorage 가 막혀 있으면 true — 안 띄우는 쪽보다 매번 띄우는 쪽이 안전하다.
|
||||
*/
|
||||
function markWarnedOnce() {
|
||||
try {
|
||||
if (!global.sessionStorage) {
|
||||
return true;
|
||||
}
|
||||
if (global.sessionStorage.getItem(WARN_KEY) === '1') {
|
||||
return false;
|
||||
}
|
||||
global.sessionStorage.setItem(WARN_KEY, '1');
|
||||
} catch (e) {
|
||||
// 접근 자체가 막힌 경우. 매번 띄운다.
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------- 선언적 훅 ----------
|
||||
|
||||
/**
|
||||
* <form data-encrypt-fields="password,confirmPassword"> 를 만나면 submit 을 가로채
|
||||
* 암호화 후 다시 제출한다. form.submit() 을 직접 호출하는 화면(로그인 등)은
|
||||
* submit 이벤트가 발생하지 않으므로 encryptForm 을 명시적으로 호출해야 한다.
|
||||
*/
|
||||
function bindDeclarativeForms() {
|
||||
if (!available()) {
|
||||
return;
|
||||
}
|
||||
var forms = global.document.querySelectorAll('form[data-encrypt-fields]');
|
||||
Array.prototype.forEach.call(forms, function (form) {
|
||||
form.addEventListener('submit', function (event) {
|
||||
if (form.getAttribute('data-encrypt-done') === 'true') {
|
||||
form.removeAttribute('data-encrypt-done');
|
||||
return;
|
||||
}
|
||||
// 페이지의 다른 submit 핸들러가 이미 제출을 막았다면(검증 실패 등)
|
||||
// 여기서 재제출하면 그 검증을 우회하게 된다.
|
||||
if (event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
var names = (form.getAttribute('data-encrypt-fields') || '')
|
||||
.split(',')
|
||||
.map(function (name) { return name.trim(); })
|
||||
.filter(function (name) { return name.length > 0; });
|
||||
if (names.length === 0) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
encryptForm(form, names).then(function () {
|
||||
form.setAttribute('data-encrypt-done', 'true');
|
||||
if (typeof form.requestSubmit === 'function') {
|
||||
form.requestSubmit();
|
||||
} else {
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function start() {
|
||||
bindDeclarativeForms();
|
||||
warnInsecureTransport();
|
||||
}
|
||||
|
||||
// DOMContentLoaded 가 아니라 load 시점에 건다. 페이지의 검증 핸들러는 대부분
|
||||
// DOMContentLoaded/$(function) 에서 등록되므로, 그보다 늦게 등록해야 우리 리스너가 마지막에 실행되어
|
||||
// 앞선 핸들러의 preventDefault(검증 실패)를 정확히 감지할 수 있다.
|
||||
// 경고 팝업도 같은 시점이어야 custom-popups.js 와 팝업 프래그먼트가 준비된 뒤에 뜬다.
|
||||
if (global.document) {
|
||||
if (global.document.readyState === 'complete') {
|
||||
start();
|
||||
} else {
|
||||
global.addEventListener('load', start);
|
||||
}
|
||||
}
|
||||
|
||||
global.portalPasswordCrypto = {
|
||||
available: available,
|
||||
engine: function () {
|
||||
var current = available() ? engine() : null;
|
||||
return current ? current.name : null;
|
||||
},
|
||||
encryptForm: encryptForm,
|
||||
encryptValues: encryptValues
|
||||
};
|
||||
})(window);
|
||||
@@ -91,7 +91,10 @@
|
||||
var seg = document.getElementById('tfaSegment');
|
||||
seg.innerHTML = '';
|
||||
self._maskedByType = {};
|
||||
var channels = info.channels || [];
|
||||
// SMS를 먼저 표시하고 첫 번째 수단을 기본 선택한다.
|
||||
var channels = (info.channels || []).slice().sort(function (a, b) {
|
||||
return (a.type === 'SMS' ? 0 : 1) - (b.type === 'SMS' ? 0 : 1);
|
||||
});
|
||||
channels.forEach(function (ch, idx) {
|
||||
self._maskedByType[ch.type] = ch.masked;
|
||||
var btn = document.createElement('button');
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,173 +0,0 @@
|
||||
|
||||
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* AUTO-GENERATED FILE. DO NOT MODIFY.
|
||||
*/
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports);
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({});
|
||||
}
|
||||
})(this, function(exports) {
|
||||
|
||||
|
||||
/**
|
||||
* Language: English.
|
||||
*/
|
||||
|
||||
var localeObj = {
|
||||
time: {
|
||||
month: [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
],
|
||||
monthAbbr: [
|
||||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
|
||||
],
|
||||
dayOfWeek: [
|
||||
'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'
|
||||
],
|
||||
dayOfWeekAbbr: [
|
||||
'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'
|
||||
]
|
||||
},
|
||||
legend: {
|
||||
selector: {
|
||||
all: 'All',
|
||||
inverse: 'Inv'
|
||||
}
|
||||
},
|
||||
toolbox: {
|
||||
brush: {
|
||||
title: {
|
||||
rect: 'Box Select',
|
||||
polygon: 'Lasso Select',
|
||||
lineX: 'Horizontally Select',
|
||||
lineY: 'Vertically Select',
|
||||
keep: 'Keep Selections',
|
||||
clear: 'Clear Selections'
|
||||
}
|
||||
},
|
||||
dataView: {
|
||||
title: 'Data View',
|
||||
lang: ['Data View', 'Close', 'Refresh']
|
||||
},
|
||||
dataZoom: {
|
||||
title: {
|
||||
zoom: 'Zoom',
|
||||
back: 'Zoom Reset'
|
||||
}
|
||||
},
|
||||
magicType: {
|
||||
title: {
|
||||
line: 'Switch to Line Chart',
|
||||
bar: 'Switch to Bar Chart',
|
||||
stack: 'Stack',
|
||||
tiled: 'Tile'
|
||||
}
|
||||
},
|
||||
restore: {
|
||||
title: 'Restore'
|
||||
},
|
||||
saveAsImage: {
|
||||
title: 'Save as Image',
|
||||
lang: ['Right Click to Save Image']
|
||||
}
|
||||
},
|
||||
series: {
|
||||
typeNames: {
|
||||
pie: 'Pie chart',
|
||||
bar: 'Bar chart',
|
||||
line: 'Line chart',
|
||||
scatter: 'Scatter plot',
|
||||
effectScatter: 'Ripple scatter plot',
|
||||
radar: 'Radar chart',
|
||||
tree: 'Tree',
|
||||
treemap: 'Treemap',
|
||||
boxplot: 'Boxplot',
|
||||
candlestick: 'Candlestick',
|
||||
k: 'K line chart',
|
||||
heatmap: 'Heat map',
|
||||
map: 'Map',
|
||||
parallel: 'Parallel coordinate map',
|
||||
lines: 'Line graph',
|
||||
graph: 'Relationship graph',
|
||||
sankey: 'Sankey diagram',
|
||||
funnel: 'Funnel chart',
|
||||
gauge: 'Gauge',
|
||||
pictorialBar: 'Pictorial bar',
|
||||
themeRiver: 'Theme River Map',
|
||||
sunburst: 'Sunburst'
|
||||
}
|
||||
},
|
||||
aria: {
|
||||
general: {
|
||||
withTitle: 'This is a chart about "{title}"',
|
||||
withoutTitle: 'This is a chart'
|
||||
},
|
||||
series: {
|
||||
single: {
|
||||
prefix: '',
|
||||
withName: ' with type {seriesType} named {seriesName}.',
|
||||
withoutName: ' with type {seriesType}.'
|
||||
},
|
||||
multiple: {
|
||||
prefix: '. It consists of {seriesCount} series count.',
|
||||
withName: ' The {seriesId} series is a {seriesType} representing {seriesName}.',
|
||||
withoutName: ' The {seriesId} series is a {seriesType}.',
|
||||
separator: {
|
||||
middle: '',
|
||||
end: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
data: {
|
||||
allData: 'The data is as follows: ',
|
||||
partialData: 'The first {displayCnt} items are: ',
|
||||
withName: 'the data for {name} is {value}',
|
||||
withoutName: '{value}',
|
||||
separator: {
|
||||
middle: ', ',
|
||||
end: '. '
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (var key in localeObj) {
|
||||
if (localeObj.hasOwnProperty(key)) {
|
||||
exports[key] = localeObj[key];
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
@@ -1,169 +0,0 @@
|
||||
|
||||
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* AUTO-GENERATED FILE. DO NOT MODIFY.
|
||||
*/
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports', 'echarts'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports, require('echarts/lib/echarts'));
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({}, root.echarts);
|
||||
}
|
||||
})(this, function(exports, echarts) {
|
||||
|
||||
|
||||
/**
|
||||
* Language: English.
|
||||
*/
|
||||
|
||||
var localeObj = {
|
||||
time: {
|
||||
month: [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
],
|
||||
monthAbbr: [
|
||||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
|
||||
],
|
||||
dayOfWeek: [
|
||||
'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'
|
||||
],
|
||||
dayOfWeekAbbr: [
|
||||
'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'
|
||||
]
|
||||
},
|
||||
legend: {
|
||||
selector: {
|
||||
all: 'All',
|
||||
inverse: 'Inv'
|
||||
}
|
||||
},
|
||||
toolbox: {
|
||||
brush: {
|
||||
title: {
|
||||
rect: 'Box Select',
|
||||
polygon: 'Lasso Select',
|
||||
lineX: 'Horizontally Select',
|
||||
lineY: 'Vertically Select',
|
||||
keep: 'Keep Selections',
|
||||
clear: 'Clear Selections'
|
||||
}
|
||||
},
|
||||
dataView: {
|
||||
title: 'Data View',
|
||||
lang: ['Data View', 'Close', 'Refresh']
|
||||
},
|
||||
dataZoom: {
|
||||
title: {
|
||||
zoom: 'Zoom',
|
||||
back: 'Zoom Reset'
|
||||
}
|
||||
},
|
||||
magicType: {
|
||||
title: {
|
||||
line: 'Switch to Line Chart',
|
||||
bar: 'Switch to Bar Chart',
|
||||
stack: 'Stack',
|
||||
tiled: 'Tile'
|
||||
}
|
||||
},
|
||||
restore: {
|
||||
title: 'Restore'
|
||||
},
|
||||
saveAsImage: {
|
||||
title: 'Save as Image',
|
||||
lang: ['Right Click to Save Image']
|
||||
}
|
||||
},
|
||||
series: {
|
||||
typeNames: {
|
||||
pie: 'Pie chart',
|
||||
bar: 'Bar chart',
|
||||
line: 'Line chart',
|
||||
scatter: 'Scatter plot',
|
||||
effectScatter: 'Ripple scatter plot',
|
||||
radar: 'Radar chart',
|
||||
tree: 'Tree',
|
||||
treemap: 'Treemap',
|
||||
boxplot: 'Boxplot',
|
||||
candlestick: 'Candlestick',
|
||||
k: 'K line chart',
|
||||
heatmap: 'Heat map',
|
||||
map: 'Map',
|
||||
parallel: 'Parallel coordinate map',
|
||||
lines: 'Line graph',
|
||||
graph: 'Relationship graph',
|
||||
sankey: 'Sankey diagram',
|
||||
funnel: 'Funnel chart',
|
||||
gauge: 'Gauge',
|
||||
pictorialBar: 'Pictorial bar',
|
||||
themeRiver: 'Theme River Map',
|
||||
sunburst: 'Sunburst'
|
||||
}
|
||||
},
|
||||
aria: {
|
||||
general: {
|
||||
withTitle: 'This is a chart about "{title}"',
|
||||
withoutTitle: 'This is a chart'
|
||||
},
|
||||
series: {
|
||||
single: {
|
||||
prefix: '',
|
||||
withName: ' with type {seriesType} named {seriesName}.',
|
||||
withoutName: ' with type {seriesType}.'
|
||||
},
|
||||
multiple: {
|
||||
prefix: '. It consists of {seriesCount} series count.',
|
||||
withName: ' The {seriesId} series is a {seriesType} representing {seriesName}.',
|
||||
withoutName: ' The {seriesId} series is a {seriesType}.',
|
||||
separator: {
|
||||
middle: '',
|
||||
end: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
data: {
|
||||
allData: 'The data is as follows: ',
|
||||
partialData: 'The first {displayCnt} items are: ',
|
||||
withName: 'the data for {name} is {value}',
|
||||
withoutName: '{value}',
|
||||
separator: {
|
||||
middle: ', ',
|
||||
end: '. '
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
echarts.registerLocale('EN', localeObj);
|
||||
|
||||
});
|
||||
@@ -1,173 +0,0 @@
|
||||
|
||||
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* AUTO-GENERATED FILE. DO NOT MODIFY.
|
||||
*/
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports);
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({});
|
||||
}
|
||||
})(this, function(exports) {
|
||||
|
||||
|
||||
/**
|
||||
* Language: Korean.
|
||||
*/
|
||||
|
||||
var localeObj = {
|
||||
time: {
|
||||
month: [
|
||||
'1월', '2월', '3월', '4월', '5월', '6월',
|
||||
'7월', '8월', '9월', '10월', '11월', '12월'
|
||||
],
|
||||
monthAbbr: [
|
||||
'1월', '2월', '3월', '4월', '5월', '6월',
|
||||
'7월', '8월', '9월', '10월', '11월', '12월'
|
||||
],
|
||||
dayOfWeek: [
|
||||
'일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일'
|
||||
],
|
||||
dayOfWeekAbbr: [
|
||||
'일', '월', '화', '수', '목', '금', '토'
|
||||
]
|
||||
},
|
||||
legend: {
|
||||
selector: {
|
||||
all: '모두 선택',
|
||||
inverse: '선택 범위 반전'
|
||||
}
|
||||
},
|
||||
toolbox: {
|
||||
brush: {
|
||||
title: {
|
||||
rect: '사각형 선택',
|
||||
polygon: '올가미 선택',
|
||||
lineX: '수평 선택',
|
||||
lineY: '수직 선택',
|
||||
keep: '선택 유지',
|
||||
clear: '선택 지우기'
|
||||
}
|
||||
},
|
||||
dataView: {
|
||||
title: '날짜 보기',
|
||||
lang: ['날짜 보기', '닫기', '새로 고침']
|
||||
},
|
||||
dataZoom: {
|
||||
title: {
|
||||
zoom: '확대/축소',
|
||||
back: '확대/축소 초기화'
|
||||
}
|
||||
},
|
||||
magicType: {
|
||||
title: {
|
||||
line: '꺽은선 그래프로 변경',
|
||||
bar: '막대 그래프로 변경',
|
||||
stack: '스택',
|
||||
tiled: '타일'
|
||||
}
|
||||
},
|
||||
restore: {
|
||||
title: '복구'
|
||||
},
|
||||
saveAsImage: {
|
||||
title: '이미지로 저장',
|
||||
lang: ['이미지를 저장하려면 마우스 오른쪽 버튼을 클릭하세요.']
|
||||
}
|
||||
},
|
||||
series: {
|
||||
typeNames: {
|
||||
pie: '원 그래프',
|
||||
bar: '막대 그래프',
|
||||
line: '꺽은선 그래프',
|
||||
scatter: '산점도',
|
||||
effectScatter: '물결 효과 산점도',
|
||||
radar: '방사형 그래프',
|
||||
tree: '트리',
|
||||
treemap: '트리맵',
|
||||
boxplot: '상자 수염 그래프',
|
||||
candlestick: '캔들스틱 차트',
|
||||
k: 'K 라인 차트',
|
||||
heatmap: '히트 맵',
|
||||
map: '지도',
|
||||
parallel: '평행 좌표 맵',
|
||||
lines: '선',
|
||||
graph: '관계 그래프',
|
||||
sankey: '산키 다이어그램',
|
||||
funnel: '깔때기형 그래프',
|
||||
gauge: '계기',
|
||||
pictorialBar: '픽토그램 차트',
|
||||
themeRiver: '스트림 그래프',
|
||||
sunburst: '선버스트 차트'
|
||||
}
|
||||
},
|
||||
aria: {
|
||||
general: {
|
||||
withTitle: '"{title}"에 대한 차트입니다.',
|
||||
withoutTitle: '차트입니다.'
|
||||
},
|
||||
series: {
|
||||
single: {
|
||||
prefix: '',
|
||||
withName: ' 차트 유형은 {seriesType}이며 {seriesName}을 표시합니다.',
|
||||
withoutName: ' 차트 유형은 {seriesType}입니다.'
|
||||
},
|
||||
multiple: {
|
||||
prefix: '. {seriesCount} 하나의 차트 시리즈로 구성됩니다.',
|
||||
withName: ' {seriesId}번째 시리즈는 {seriesName}을 나타내는 {seriesType} representing.',
|
||||
withoutName: ' {seriesId}번째 시리즈는 {seriesType}입니다.',
|
||||
separator: {
|
||||
middle: '',
|
||||
end: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
data: {
|
||||
allData: '데이터: ',
|
||||
partialData: '첫번째 {displayCnt} 아이템: ',
|
||||
withName: '{name}의 데이터는 {value}',
|
||||
withoutName: '{value}',
|
||||
separator: {
|
||||
middle: ', ',
|
||||
end: '. '
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (var key in localeObj) {
|
||||
if (localeObj.hasOwnProperty(key)) {
|
||||
exports[key] = localeObj[key];
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
@@ -1,169 +0,0 @@
|
||||
|
||||
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* AUTO-GENERATED FILE. DO NOT MODIFY.
|
||||
*/
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports', 'echarts'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports, require('echarts/lib/echarts'));
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({}, root.echarts);
|
||||
}
|
||||
})(this, function(exports, echarts) {
|
||||
|
||||
|
||||
/**
|
||||
* Language: Korean.
|
||||
*/
|
||||
|
||||
var localeObj = {
|
||||
time: {
|
||||
month: [
|
||||
'1월', '2월', '3월', '4월', '5월', '6월',
|
||||
'7월', '8월', '9월', '10월', '11월', '12월'
|
||||
],
|
||||
monthAbbr: [
|
||||
'1월', '2월', '3월', '4월', '5월', '6월',
|
||||
'7월', '8월', '9월', '10월', '11월', '12월'
|
||||
],
|
||||
dayOfWeek: [
|
||||
'일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일'
|
||||
],
|
||||
dayOfWeekAbbr: [
|
||||
'일', '월', '화', '수', '목', '금', '토'
|
||||
]
|
||||
},
|
||||
legend: {
|
||||
selector: {
|
||||
all: '모두 선택',
|
||||
inverse: '선택 범위 반전'
|
||||
}
|
||||
},
|
||||
toolbox: {
|
||||
brush: {
|
||||
title: {
|
||||
rect: '사각형 선택',
|
||||
polygon: '올가미 선택',
|
||||
lineX: '수평 선택',
|
||||
lineY: '수직 선택',
|
||||
keep: '선택 유지',
|
||||
clear: '선택 지우기'
|
||||
}
|
||||
},
|
||||
dataView: {
|
||||
title: '날짜 보기',
|
||||
lang: ['날짜 보기', '닫기', '새로 고침']
|
||||
},
|
||||
dataZoom: {
|
||||
title: {
|
||||
zoom: '확대/축소',
|
||||
back: '확대/축소 초기화'
|
||||
}
|
||||
},
|
||||
magicType: {
|
||||
title: {
|
||||
line: '꺽은선 그래프로 변경',
|
||||
bar: '막대 그래프로 변경',
|
||||
stack: '스택',
|
||||
tiled: '타일'
|
||||
}
|
||||
},
|
||||
restore: {
|
||||
title: '복구'
|
||||
},
|
||||
saveAsImage: {
|
||||
title: '이미지로 저장',
|
||||
lang: ['이미지를 저장하려면 마우스 오른쪽 버튼을 클릭하세요.']
|
||||
}
|
||||
},
|
||||
series: {
|
||||
typeNames: {
|
||||
pie: '원 그래프',
|
||||
bar: '막대 그래프',
|
||||
line: '꺽은선 그래프',
|
||||
scatter: '산점도',
|
||||
effectScatter: '물결 효과 산점도',
|
||||
radar: '방사형 그래프',
|
||||
tree: '트리',
|
||||
treemap: '트리맵',
|
||||
boxplot: '상자 수염 그래프',
|
||||
candlestick: '캔들스틱 차트',
|
||||
k: 'K 라인 차트',
|
||||
heatmap: '히트 맵',
|
||||
map: '지도',
|
||||
parallel: '평행 좌표 맵',
|
||||
lines: '선',
|
||||
graph: '관계 그래프',
|
||||
sankey: '산키 다이어그램',
|
||||
funnel: '깔때기형 그래프',
|
||||
gauge: '계기',
|
||||
pictorialBar: '픽토그램 차트',
|
||||
themeRiver: '스트림 그래프',
|
||||
sunburst: '선버스트 차트'
|
||||
}
|
||||
},
|
||||
aria: {
|
||||
general: {
|
||||
withTitle: '"{title}"에 대한 차트입니다.',
|
||||
withoutTitle: '차트입니다.'
|
||||
},
|
||||
series: {
|
||||
single: {
|
||||
prefix: '',
|
||||
withName: ' 차트 유형은 {seriesType}이며 {seriesName}을 표시합니다.',
|
||||
withoutName: ' 차트 유형은 {seriesType}입니다.'
|
||||
},
|
||||
multiple: {
|
||||
prefix: '. {seriesCount} 하나의 차트 시리즈로 구성됩니다.',
|
||||
withName: ' {seriesId}번째 시리즈는 {seriesName}을 나타내는 {seriesType} representing.',
|
||||
withoutName: ' {seriesId}번째 시리즈는 {seriesType}입니다.',
|
||||
separator: {
|
||||
middle: '',
|
||||
end: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
data: {
|
||||
allData: '데이터: ',
|
||||
partialData: '첫번째 {displayCnt} 아이템: ',
|
||||
withName: '{name}의 데이터는 {value}',
|
||||
withoutName: '{value}',
|
||||
separator: {
|
||||
middle: ', ',
|
||||
end: '. '
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
echarts.registerLocale('KO', localeObj);
|
||||
|
||||
});
|
||||
@@ -1,163 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports', 'echarts'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports, require('echarts/lib/echarts'));
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({}, root.echarts);
|
||||
}
|
||||
})(this, function(exports, echarts) {
|
||||
var log = function(msg) {
|
||||
if (typeof console !== 'undefined') {
|
||||
console && console.error && console.error(msg);
|
||||
}
|
||||
};
|
||||
if (!echarts) {
|
||||
log('ECharts is not Loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
var colorPalette = [
|
||||
'#f2385a',
|
||||
'#f5a503',
|
||||
'#4ad9d9',
|
||||
'#f7879c',
|
||||
'#c1d7a8',
|
||||
'#4dffd2',
|
||||
'#fccfd7',
|
||||
'#d5f6f6'
|
||||
];
|
||||
|
||||
var theme = {
|
||||
color: colorPalette,
|
||||
|
||||
title: {
|
||||
textStyle: {
|
||||
fontWeight: 'normal',
|
||||
color: '#f2385a'
|
||||
}
|
||||
},
|
||||
|
||||
visualMap: {
|
||||
color: ['#f2385a', '#f5a503']
|
||||
},
|
||||
|
||||
toolbox: {
|
||||
color: ['#f2385a', '#f2385a', '#f2385a', '#f2385a']
|
||||
},
|
||||
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
axisPointer: {
|
||||
// Axis indicator, coordinate trigger effective
|
||||
type: 'line', // The default is a straight line: 'line' | 'shadow'
|
||||
lineStyle: {
|
||||
// Straight line indicator style settings
|
||||
color: '#f2385a',
|
||||
type: 'dashed'
|
||||
},
|
||||
crossStyle: {
|
||||
color: '#f2385a'
|
||||
},
|
||||
shadowStyle: {
|
||||
// Shadow indicator style settings
|
||||
color: 'rgba(200,200,200,0.3)'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Area scaling controller
|
||||
dataZoom: {
|
||||
dataBackgroundColor: '#eee', // Data background color
|
||||
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
|
||||
handleColor: '#f2385a' // Handle color
|
||||
},
|
||||
|
||||
timeline: {
|
||||
lineStyle: {
|
||||
color: '#f2385a'
|
||||
},
|
||||
controlStyle: {
|
||||
color: '#f2385a',
|
||||
borderColor: '#f2385a'
|
||||
}
|
||||
},
|
||||
|
||||
candlestick: {
|
||||
itemStyle: {
|
||||
color: '#f2385a',
|
||||
color0: '#f5a503'
|
||||
},
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: '#f2385a',
|
||||
color0: '#f5a503'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#c1d7a8',
|
||||
color0: '#4ad9d9'
|
||||
}
|
||||
},
|
||||
|
||||
map: {
|
||||
itemStyle: {
|
||||
color: '#f2385a'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#ddd'
|
||||
},
|
||||
label: {
|
||||
color: '#c12e34'
|
||||
}
|
||||
},
|
||||
|
||||
graph: {
|
||||
itemStyle: {
|
||||
color: '#f2385a'
|
||||
},
|
||||
linkStyle: {
|
||||
color: '#f2385a'
|
||||
}
|
||||
},
|
||||
|
||||
gauge: {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: [
|
||||
[0.2, '#f5a503'],
|
||||
[0.8, '#f2385a'],
|
||||
[1, '#c1d7a8']
|
||||
],
|
||||
width: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
echarts.registerTheme('azul', theme);
|
||||
});
|
||||
@@ -1,178 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports', 'echarts'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports, require('echarts/lib/echarts'));
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({}, root.echarts);
|
||||
}
|
||||
})(this, function(exports, echarts) {
|
||||
var log = function(msg) {
|
||||
if (typeof console !== 'undefined') {
|
||||
console && console.error && console.error(msg);
|
||||
}
|
||||
};
|
||||
if (!echarts) {
|
||||
log('ECharts is not Loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
var colorPalette = [
|
||||
'#001727',
|
||||
'#805500',
|
||||
'#ffff00',
|
||||
'#ffd11a',
|
||||
'#f2d71f',
|
||||
'#f2be19',
|
||||
'#f3a81a',
|
||||
'#fff5cc'
|
||||
];
|
||||
|
||||
var theme = {
|
||||
color: colorPalette,
|
||||
|
||||
title: {
|
||||
textStyle: {
|
||||
fontWeight: 'normal',
|
||||
color: '#001727'
|
||||
}
|
||||
},
|
||||
|
||||
visualMap: {
|
||||
color: ['#001727', '#805500']
|
||||
},
|
||||
|
||||
toolbox: {
|
||||
color: ['#001727', '#001727', '#001727', '#001727']
|
||||
},
|
||||
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
axisPointer: {
|
||||
// Axis indicator, coordinate trigger effective
|
||||
type: 'line', // The default is a straight line: 'line' | 'shadow'
|
||||
lineStyle: {
|
||||
// Straight line indicator style settings
|
||||
color: '#001727',
|
||||
type: 'dashed'
|
||||
},
|
||||
crossStyle: {
|
||||
color: '#001727'
|
||||
},
|
||||
shadowStyle: {
|
||||
// Shadow indicator style settings
|
||||
color: 'rgba(200,200,200,0.3)'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Area scaling controller
|
||||
dataZoom: {
|
||||
dataBackgroundColor: '#eee', // Data background color
|
||||
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
|
||||
handleColor: '#001727' // Handle color
|
||||
},
|
||||
|
||||
timeline: {
|
||||
lineStyle: {
|
||||
color: '#001727'
|
||||
},
|
||||
controlStyle: {
|
||||
color: '#001727',
|
||||
borderColor: '#001727'
|
||||
}
|
||||
},
|
||||
|
||||
candlestick: {
|
||||
itemStyle: {
|
||||
color: '#f3a81a',
|
||||
color0: '#ffff00'
|
||||
},
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: '#ffff00',
|
||||
color0: '#f3a81a'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#805500',
|
||||
color0: '#ffff00'
|
||||
}
|
||||
},
|
||||
|
||||
chord: {
|
||||
padding: 4,
|
||||
itemStyle: {
|
||||
color: '#f3a81a',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(128, 128, 128, 0.5)'
|
||||
},
|
||||
lineStyle: {
|
||||
color: 'rgba(128, 128, 128, 0.5)'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#805500'
|
||||
}
|
||||
},
|
||||
|
||||
map: {
|
||||
itemStyle: {
|
||||
color: '#ffd11a'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#f2be19'
|
||||
},
|
||||
label: {
|
||||
color: '#ffd11a'
|
||||
}
|
||||
},
|
||||
|
||||
graph: {
|
||||
itemStyle: {
|
||||
color: '#001727'
|
||||
},
|
||||
linkStyle: {
|
||||
color: '#001727'
|
||||
}
|
||||
},
|
||||
|
||||
gauge: {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: [
|
||||
[0.2, '#f2d71f'],
|
||||
[0.8, '#001727'],
|
||||
[1, '#ffff00']
|
||||
],
|
||||
width: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
echarts.registerTheme('bee-inspired', theme);
|
||||
});
|
||||
@@ -1,178 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports', 'echarts'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports, require('echarts/lib/echarts'));
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({}, root.echarts);
|
||||
}
|
||||
})(this, function(exports, echarts) {
|
||||
var log = function(msg) {
|
||||
if (typeof console !== 'undefined') {
|
||||
console && console.error && console.error(msg);
|
||||
}
|
||||
};
|
||||
if (!echarts) {
|
||||
log('ECharts is not Loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
var colorPalette = [
|
||||
'#1790cf',
|
||||
'#1bb2d8',
|
||||
'#99d2dd',
|
||||
'#88b0bb',
|
||||
'#1c7099',
|
||||
'#038cc4',
|
||||
'#75abd0',
|
||||
'#afd6dd'
|
||||
];
|
||||
|
||||
var theme = {
|
||||
color: colorPalette,
|
||||
|
||||
title: {
|
||||
textStyle: {
|
||||
fontWeight: 'normal',
|
||||
color: '#1790cf'
|
||||
}
|
||||
},
|
||||
|
||||
visualMap: {
|
||||
color: ['#1790cf', '#a2d4e6']
|
||||
},
|
||||
|
||||
toolbox: {
|
||||
color: ['#1790cf', '#1790cf', '#1790cf', '#1790cf']
|
||||
},
|
||||
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
axisPointer: {
|
||||
// Axis indicator, coordinate trigger effective
|
||||
type: 'line', // The default is a straight line: 'line' | 'shadow'
|
||||
lineStyle: {
|
||||
// Straight line indicator style settings
|
||||
color: '#1790cf',
|
||||
type: 'dashed'
|
||||
},
|
||||
crossStyle: {
|
||||
color: '#1790cf'
|
||||
},
|
||||
shadowStyle: {
|
||||
// Shadow indicator style settings
|
||||
color: 'rgba(200,200,200,0.3)'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Area scaling controller
|
||||
dataZoom: {
|
||||
dataBackgroundColor: '#eee', // Data background color
|
||||
fillerColor: 'rgba(144,197,237,0.2)', // Fill the color
|
||||
handleColor: '#1790cf' // Handle color
|
||||
},
|
||||
|
||||
timeline: {
|
||||
lineStyle: {
|
||||
color: '#1790cfa'
|
||||
},
|
||||
controlStyle: {
|
||||
color: '#1790cf',
|
||||
borderColor: '#1790cf'
|
||||
}
|
||||
},
|
||||
|
||||
candlestick: {
|
||||
itemStyle: {
|
||||
color: '#1bb2d8',
|
||||
color0: '#99d2dd'
|
||||
},
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: '#1c7099',
|
||||
color0: '#88b0bb'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#1790cf',
|
||||
color0: '#1bb2d8'
|
||||
}
|
||||
},
|
||||
|
||||
chord: {
|
||||
padding: 4,
|
||||
itemStyle: {
|
||||
color: '#1bb2d8',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(128, 128, 128, 0.5)'
|
||||
},
|
||||
lineStyle: {
|
||||
color: 'rgba(128, 128, 128, 0.5)'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#1790cf'
|
||||
}
|
||||
},
|
||||
|
||||
graph: {
|
||||
itemStyle: {
|
||||
color: '#1bb2d8'
|
||||
},
|
||||
linkStyle: {
|
||||
color: '#88b0bb'
|
||||
}
|
||||
},
|
||||
|
||||
map: {
|
||||
itemStyle: {
|
||||
color: '#ddd'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '99d2dd'
|
||||
},
|
||||
label: {
|
||||
color: '#c12e34'
|
||||
}
|
||||
},
|
||||
|
||||
gauge: {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: [
|
||||
[0.2, '#1bb2d8'],
|
||||
[0.8, '#1790cf'],
|
||||
[1, '#1c7099']
|
||||
],
|
||||
width: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
echarts.registerTheme('blue', theme);
|
||||
});
|
||||
@@ -1,178 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports', 'echarts'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports, require('echarts/lib/echarts'));
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({}, root.echarts);
|
||||
}
|
||||
})(this, function(exports, echarts) {
|
||||
var log = function(msg) {
|
||||
if (typeof console !== 'undefined') {
|
||||
console && console.error && console.error(msg);
|
||||
}
|
||||
};
|
||||
if (!echarts) {
|
||||
log('ECharts is not Loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
var colorPalette = [
|
||||
'#fad089',
|
||||
'#ff9c5b',
|
||||
'#f5634a',
|
||||
'#ed303c',
|
||||
'#3b8183',
|
||||
'#f7826e',
|
||||
'#faac9e',
|
||||
'#fcd5cf'
|
||||
];
|
||||
|
||||
var theme = {
|
||||
color: colorPalette,
|
||||
|
||||
title: {
|
||||
textStyle: {
|
||||
fontWeight: 'normal',
|
||||
color: '#fad089'
|
||||
}
|
||||
},
|
||||
|
||||
visualMap: {
|
||||
color: ['#fad089', '#a2d4e6']
|
||||
},
|
||||
|
||||
toolbox: {
|
||||
color: ['#fad089', '#fad089', '#fad089', '#fad089']
|
||||
},
|
||||
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
axisPointer: {
|
||||
// Axis indicator, coordinate trigger effective
|
||||
type: 'line', // The default is a straight line: 'line' | 'shadow'
|
||||
lineStyle: {
|
||||
// Straight line indicator style settings
|
||||
color: '#fad089',
|
||||
type: 'dashed'
|
||||
},
|
||||
crossStyle: {
|
||||
color: '#fad089'
|
||||
},
|
||||
shadowStyle: {
|
||||
// Shadow indicator style settings
|
||||
color: 'rgba(200,200,200,0.3)'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Area scaling controller
|
||||
dataZoom: {
|
||||
dataBackgroundColor: '#eee', // Data background color
|
||||
fillerColor: 'rgba(144,197,237,0.2)', // Fill the color
|
||||
handleColor: '#fad089' // Handle color
|
||||
},
|
||||
|
||||
timeline: {
|
||||
lineStyle: {
|
||||
color: '#fad089'
|
||||
},
|
||||
controlStyle: {
|
||||
color: '#fad089',
|
||||
borderColor: '#fad089'
|
||||
}
|
||||
},
|
||||
|
||||
candlestick: {
|
||||
itemStyle: {
|
||||
color: '#ff9c5b',
|
||||
color0: '#f5634a'
|
||||
},
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: '#3b8183',
|
||||
color0: '#ed303c'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#fad089',
|
||||
color0: '#ed303c'
|
||||
}
|
||||
},
|
||||
|
||||
chord: {
|
||||
padding: 4,
|
||||
itemStyle: {
|
||||
color: '#fad089',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(128, 128, 128, 0.5)'
|
||||
},
|
||||
lineStyle: {
|
||||
color: 'rgba(128, 128, 128, 0.5)'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#ed303c'
|
||||
}
|
||||
},
|
||||
|
||||
map: {
|
||||
itemStyle: {
|
||||
color: '#ddd'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#f5634a'
|
||||
},
|
||||
label: {
|
||||
color: '#c12e34'
|
||||
}
|
||||
},
|
||||
|
||||
graph: {
|
||||
itemStyle: {
|
||||
color: '#f5634a'
|
||||
},
|
||||
linkStyle: {
|
||||
color: '#fad089'
|
||||
}
|
||||
},
|
||||
|
||||
gauge: {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: [
|
||||
[0.2, '#ff9c5b'],
|
||||
[0.8, '#fad089'],
|
||||
[1, '#3b8183']
|
||||
],
|
||||
width: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
echarts.registerTheme('caravan', theme);
|
||||
});
|
||||
@@ -1,163 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports', 'echarts'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports, require('echarts/lib/echarts'));
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({}, root.echarts);
|
||||
}
|
||||
})(this, function(exports, echarts) {
|
||||
var log = function(msg) {
|
||||
if (typeof console !== 'undefined') {
|
||||
console && console.error && console.error(msg);
|
||||
}
|
||||
};
|
||||
if (!echarts) {
|
||||
log('ECharts is not Loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
var colorPalette = [
|
||||
'#f0d8A8',
|
||||
'#3d1c00',
|
||||
'#86b8b1',
|
||||
'#f2d694',
|
||||
'#fa2a00',
|
||||
'#ff8066',
|
||||
'#ffd5cc',
|
||||
'#f9edd2'
|
||||
];
|
||||
|
||||
var theme = {
|
||||
color: colorPalette,
|
||||
|
||||
title: {
|
||||
textStyle: {
|
||||
fontWeight: 'normal',
|
||||
color: '#f0d8A8'
|
||||
}
|
||||
},
|
||||
|
||||
visualMap: {
|
||||
color: ['#f0d8A8', '#3d1c00']
|
||||
},
|
||||
|
||||
toolbox: {
|
||||
color: ['#f0d8A8', '#f0d8A8', '#f0d8A8', '#f0d8A8']
|
||||
},
|
||||
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
axisPointer: {
|
||||
// Axis indicator, coordinate trigger effective
|
||||
type: 'line', // The default is a straight line: 'line' | 'shadow'
|
||||
lineStyle: {
|
||||
// Straight line indicator style settings
|
||||
color: '#f0d8A8',
|
||||
type: 'dashed'
|
||||
},
|
||||
crossStyle: {
|
||||
color: '#f0d8A8'
|
||||
},
|
||||
shadowStyle: {
|
||||
// Shadow indicator style settings
|
||||
color: 'rgba(200,200,200,0.3)'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Area scaling controller
|
||||
dataZoom: {
|
||||
dataBackgroundColor: '#eee', // Data background color
|
||||
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
|
||||
handleColor: '#f0d8A8' // Handle color
|
||||
},
|
||||
|
||||
timeline: {
|
||||
lineStyle: {
|
||||
color: '#f0dba8'
|
||||
},
|
||||
controlStyle: {
|
||||
color: '#f0dba8',
|
||||
borderColor: '#f0dba8'
|
||||
}
|
||||
},
|
||||
|
||||
candlestick: {
|
||||
itemStyle: {
|
||||
color: '#3d1c00',
|
||||
color0: '#86b8b1'
|
||||
},
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: '#fa2a00',
|
||||
color0: '#f2d694'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#f0d8A8',
|
||||
color0: '#86b8b1'
|
||||
}
|
||||
},
|
||||
|
||||
map: {
|
||||
itemStyle: {
|
||||
color: '#ddd'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#86b8b1'
|
||||
},
|
||||
label: {
|
||||
color: '#c12e34'
|
||||
}
|
||||
},
|
||||
|
||||
graph: {
|
||||
itemStyle: {
|
||||
color: '#3d1c00'
|
||||
},
|
||||
linkStyle: {
|
||||
color: '#f0d8A8'
|
||||
}
|
||||
},
|
||||
|
||||
gauge: {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: [
|
||||
[0.2, '#3d1c00'],
|
||||
[0.8, '#f0d8A8'],
|
||||
[1, '#fa2a00']
|
||||
],
|
||||
width: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
echarts.registerTheme('carp', theme);
|
||||
});
|
||||
@@ -1,180 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports', 'echarts'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports, require('echarts/lib/echarts'));
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({}, root.echarts);
|
||||
}
|
||||
})(this, function(exports, echarts) {
|
||||
var log = function(msg) {
|
||||
if (typeof console !== 'undefined') {
|
||||
console && console.error && console.error(msg);
|
||||
}
|
||||
};
|
||||
if (!echarts) {
|
||||
log('ECharts is not Loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
var colorPalette = [
|
||||
'#b21ab4',
|
||||
'#6f0099',
|
||||
'#2a2073',
|
||||
'#0b5ea8',
|
||||
'#17aecc',
|
||||
'#b3b3ff',
|
||||
'#eb99ff',
|
||||
'#fae6ff',
|
||||
'#e6f2ff',
|
||||
'#eeeeee'
|
||||
];
|
||||
|
||||
var theme = {
|
||||
color: colorPalette,
|
||||
|
||||
title: {
|
||||
textStyle: {
|
||||
fontWeight: 'normal',
|
||||
color: '#00aecd'
|
||||
}
|
||||
},
|
||||
|
||||
visualMap: {
|
||||
color: ['#00aecd', '#a2d4e6']
|
||||
},
|
||||
|
||||
toolbox: {
|
||||
color: ['#00aecd', '#00aecd', '#00aecd', '#00aecd']
|
||||
},
|
||||
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
axisPointer: {
|
||||
// Axis indicator, coordinate trigger effective
|
||||
type: 'line', // The default is a straight line: 'line' | 'shadow'
|
||||
lineStyle: {
|
||||
// Straight line indicator style settings
|
||||
color: '#00aecd',
|
||||
type: 'dashed'
|
||||
},
|
||||
crossStyle: {
|
||||
color: '#00aecd'
|
||||
},
|
||||
shadowStyle: {
|
||||
// Shadow indicator style settings
|
||||
color: 'rgba(200,200,200,0.3)'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Area scaling controller
|
||||
dataZoom: {
|
||||
dataBackgroundColor: '#eee', // Data background color
|
||||
fillerColor: 'rgba(144,197,237,0.2)', // Fill the color
|
||||
handleColor: '#00aecd' // Handle color
|
||||
},
|
||||
|
||||
timeline: {
|
||||
lineStyle: {
|
||||
color: '#00aecd'
|
||||
},
|
||||
controlStyle: {
|
||||
color: '#00aecd',
|
||||
borderColor: '00aecd'
|
||||
}
|
||||
},
|
||||
|
||||
candlestick: {
|
||||
itemStyle: {
|
||||
color: '#00aecd',
|
||||
color0: '#a2d4e6'
|
||||
},
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: '#00aecd',
|
||||
color0: '#a2d4e6'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#b21ab4',
|
||||
color0: '#0b5ea8'
|
||||
}
|
||||
},
|
||||
|
||||
chord: {
|
||||
padding: 4,
|
||||
itemStyle: {
|
||||
color: '#b21ab4',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(128, 128, 128, 0.5)'
|
||||
},
|
||||
lineStyle: {
|
||||
color: 'rgba(128, 128, 128, 0.5)'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#0b5ea8'
|
||||
}
|
||||
},
|
||||
|
||||
graph: {
|
||||
itemStyle: {
|
||||
color: '#b21ab4'
|
||||
},
|
||||
linkStyle: {
|
||||
color: '#2a2073'
|
||||
}
|
||||
},
|
||||
|
||||
map: {
|
||||
itemStyle: {
|
||||
color: '#c12e34'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#ddd'
|
||||
},
|
||||
label: {
|
||||
color: '#c12e34'
|
||||
}
|
||||
},
|
||||
|
||||
gauge: {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: [
|
||||
[0.2, '#dddddd'],
|
||||
[0.8, '#00aecd'],
|
||||
[1, '#f5ccff']
|
||||
],
|
||||
width: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
echarts.registerTheme('cool', theme);
|
||||
});
|
||||
@@ -1,164 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports', 'echarts'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports, require('echarts/lib/echarts'));
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({}, root.echarts);
|
||||
}
|
||||
})(this, function(exports, echarts) {
|
||||
var log = function(msg) {
|
||||
if (typeof console !== 'undefined') {
|
||||
console && console.error && console.error(msg);
|
||||
}
|
||||
};
|
||||
if (!echarts) {
|
||||
log('ECharts is not Loaded');
|
||||
return;
|
||||
}
|
||||
var contrastColor = '#eee';
|
||||
var axisCommon = function() {
|
||||
return {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: contrastColor
|
||||
}
|
||||
},
|
||||
axisTick: {
|
||||
lineStyle: {
|
||||
color: contrastColor
|
||||
}
|
||||
},
|
||||
axisLabel: {
|
||||
color: contrastColor
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
type: 'dashed',
|
||||
color: '#aaa'
|
||||
}
|
||||
},
|
||||
splitArea: {
|
||||
areaStyle: {
|
||||
color: contrastColor
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
var colorPalette = [
|
||||
'#00305a',
|
||||
'#004b8d',
|
||||
'#0074d9',
|
||||
'#4192d9',
|
||||
'#7abaf2',
|
||||
'#99cce6',
|
||||
'#d6ebf5',
|
||||
'#eeeeee'
|
||||
];
|
||||
var theme = {
|
||||
color: colorPalette,
|
||||
backgroundColor: '#333',
|
||||
tooltip: {
|
||||
axisPointer: {
|
||||
lineStyle: {
|
||||
color: contrastColor
|
||||
},
|
||||
crossStyle: {
|
||||
color: contrastColor
|
||||
}
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
textStyle: {
|
||||
color: contrastColor
|
||||
}
|
||||
},
|
||||
title: {
|
||||
textStyle: {
|
||||
color: contrastColor
|
||||
}
|
||||
},
|
||||
toolbox: {
|
||||
iconStyle: {
|
||||
borderColor: contrastColor
|
||||
}
|
||||
},
|
||||
|
||||
// Area scaling controller
|
||||
dataZoom: {
|
||||
dataBackgroundColor: '#eee', // Data background color
|
||||
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
|
||||
handleColor: '#00305a' // Handle color
|
||||
},
|
||||
|
||||
timeline: {
|
||||
itemStyle: {
|
||||
color: colorPalette[1]
|
||||
},
|
||||
lineStyle: {
|
||||
color: contrastColor
|
||||
},
|
||||
controlStyle: {
|
||||
color: contrastColor,
|
||||
borderColor: contrastColor
|
||||
},
|
||||
label: {
|
||||
color: contrastColor
|
||||
}
|
||||
},
|
||||
|
||||
timeAxis: axisCommon(),
|
||||
logAxis: axisCommon(),
|
||||
valueAxis: axisCommon(),
|
||||
categoryAxis: axisCommon(),
|
||||
|
||||
line: {
|
||||
symbol: 'circle'
|
||||
},
|
||||
graph: {
|
||||
color: colorPalette
|
||||
},
|
||||
|
||||
gauge: {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: [
|
||||
[0.2, '#004b8d'],
|
||||
[0.8, '#00305a'],
|
||||
[1, '#7abaf2']
|
||||
],
|
||||
width: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
theme.categoryAxis.splitLine.show = false;
|
||||
echarts.registerTheme('dark-blue', theme);
|
||||
});
|
||||
@@ -1,164 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports', 'echarts'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports, require('echarts/lib/echarts'));
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({}, root.echarts);
|
||||
}
|
||||
})(this, function(exports, echarts) {
|
||||
var log = function(msg) {
|
||||
if (typeof console !== 'undefined') {
|
||||
console && console.error && console.error(msg);
|
||||
}
|
||||
};
|
||||
if (!echarts) {
|
||||
log('ECharts is not Loaded');
|
||||
return;
|
||||
}
|
||||
var contrastColor = '#eee';
|
||||
var axisCommon = function() {
|
||||
return {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: contrastColor
|
||||
}
|
||||
},
|
||||
axisTick: {
|
||||
lineStyle: {
|
||||
color: contrastColor
|
||||
}
|
||||
},
|
||||
axisLabel: {
|
||||
color: contrastColor
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
type: 'dashed',
|
||||
color: '#aaa'
|
||||
}
|
||||
},
|
||||
splitArea: {
|
||||
areaStyle: {
|
||||
color: contrastColor
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
var colorPalette = [
|
||||
'#458c6b',
|
||||
'#f2da87',
|
||||
'#d9a86c',
|
||||
'#d94436',
|
||||
'#a62424',
|
||||
'#76bc9b',
|
||||
'#cce6da',
|
||||
'#eeeeee'
|
||||
];
|
||||
var theme = {
|
||||
color: colorPalette,
|
||||
backgroundColor: '#333',
|
||||
tooltip: {
|
||||
axisPointer: {
|
||||
lineStyle: {
|
||||
color: contrastColor
|
||||
},
|
||||
crossStyle: {
|
||||
color: contrastColor
|
||||
}
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
textStyle: {
|
||||
color: contrastColor
|
||||
}
|
||||
},
|
||||
title: {
|
||||
textStyle: {
|
||||
color: contrastColor
|
||||
}
|
||||
},
|
||||
toolbox: {
|
||||
iconStyle: {
|
||||
borderColor: contrastColor
|
||||
}
|
||||
},
|
||||
|
||||
// Area scaling controller
|
||||
dataZoom: {
|
||||
dataBackgroundColor: '#eee', // Data background color
|
||||
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
|
||||
handleColor: '#458c6b' // Handle color
|
||||
},
|
||||
|
||||
timeline: {
|
||||
itemStyle: {
|
||||
color: colorPalette[1]
|
||||
},
|
||||
lineStyle: {
|
||||
color: contrastColor
|
||||
},
|
||||
controlStyle: {
|
||||
color: contrastColor,
|
||||
borderColor: contrastColor
|
||||
},
|
||||
label: {
|
||||
color: contrastColor
|
||||
}
|
||||
},
|
||||
|
||||
timeAxis: axisCommon(),
|
||||
logAxis: axisCommon(),
|
||||
valueAxis: axisCommon(),
|
||||
categoryAxis: axisCommon(),
|
||||
|
||||
line: {
|
||||
symbol: 'circle'
|
||||
},
|
||||
graph: {
|
||||
color: colorPalette
|
||||
},
|
||||
|
||||
gauge: {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: [
|
||||
[0.2, '#f2da87'],
|
||||
[0.8, '#458c6b'],
|
||||
[1, '#a62424']
|
||||
],
|
||||
width: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
theme.categoryAxis.splitLine.show = false;
|
||||
echarts.registerTheme('dark-bold', theme);
|
||||
});
|
||||
@@ -1,164 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports', 'echarts'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports, require('echarts/lib/echarts'));
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({}, root.echarts);
|
||||
}
|
||||
})(this, function(exports, echarts) {
|
||||
var log = function(msg) {
|
||||
if (typeof console !== 'undefined') {
|
||||
console && console.error && console.error(msg);
|
||||
}
|
||||
};
|
||||
if (!echarts) {
|
||||
log('ECharts is not Loaded');
|
||||
return;
|
||||
}
|
||||
var contrastColor = '#eee';
|
||||
var axisCommon = function() {
|
||||
return {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: contrastColor
|
||||
}
|
||||
},
|
||||
axisTick: {
|
||||
lineStyle: {
|
||||
color: contrastColor
|
||||
}
|
||||
},
|
||||
axisLabel: {
|
||||
color: contrastColor
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
type: 'dashed',
|
||||
color: '#aaa'
|
||||
}
|
||||
},
|
||||
splitArea: {
|
||||
areaStyle: {
|
||||
color: contrastColor
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
var colorPalette = [
|
||||
'#52656b',
|
||||
'#ff3b77',
|
||||
'#a3cc00',
|
||||
'#ffffff',
|
||||
'#b8b89f',
|
||||
'#ffccdb',
|
||||
'#e5ff80',
|
||||
'#f4f4f0'
|
||||
];
|
||||
var theme = {
|
||||
color: colorPalette,
|
||||
backgroundColor: '#333',
|
||||
tooltip: {
|
||||
axisPointer: {
|
||||
lineStyle: {
|
||||
color: contrastColor
|
||||
},
|
||||
crossStyle: {
|
||||
color: contrastColor
|
||||
}
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
textStyle: {
|
||||
color: contrastColor
|
||||
}
|
||||
},
|
||||
title: {
|
||||
textStyle: {
|
||||
color: contrastColor
|
||||
}
|
||||
},
|
||||
toolbox: {
|
||||
iconStyle: {
|
||||
borderColor: contrastColor
|
||||
}
|
||||
},
|
||||
|
||||
// Area scaling controller
|
||||
dataZoom: {
|
||||
dataBackgroundColor: '#eee', // Data background color
|
||||
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
|
||||
handleColor: '#52656b' // Handle color
|
||||
},
|
||||
|
||||
timeline: {
|
||||
itemStyle: {
|
||||
color: colorPalette[1]
|
||||
},
|
||||
lineStyle: {
|
||||
color: contrastColor
|
||||
},
|
||||
controlStyle: {
|
||||
color: contrastColor,
|
||||
borderColor: contrastColor
|
||||
},
|
||||
label: {
|
||||
color: contrastColor
|
||||
}
|
||||
},
|
||||
|
||||
timeAxis: axisCommon(),
|
||||
logAxis: axisCommon(),
|
||||
valueAxis: axisCommon(),
|
||||
categoryAxis: axisCommon(),
|
||||
|
||||
line: {
|
||||
symbol: 'circle'
|
||||
},
|
||||
graph: {
|
||||
color: colorPalette
|
||||
},
|
||||
|
||||
gauge: {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: [
|
||||
[0.2, '#ff3b77'],
|
||||
[0.8, '#52656b'],
|
||||
[1, '#b8b89f']
|
||||
],
|
||||
width: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
theme.categoryAxis.splitLine.show = false;
|
||||
echarts.registerTheme('dark-digerati', theme);
|
||||
});
|
||||
@@ -1,164 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports', 'echarts'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports, require('echarts/lib/echarts'));
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({}, root.echarts);
|
||||
}
|
||||
})(this, function(exports, echarts) {
|
||||
var log = function(msg) {
|
||||
if (typeof console !== 'undefined') {
|
||||
console && console.error && console.error(msg);
|
||||
}
|
||||
};
|
||||
if (!echarts) {
|
||||
log('ECharts is not Loaded');
|
||||
return;
|
||||
}
|
||||
var contrastColor = '#eee';
|
||||
var axisCommon = function() {
|
||||
return {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: contrastColor
|
||||
}
|
||||
},
|
||||
axisTick: {
|
||||
lineStyle: {
|
||||
color: contrastColor
|
||||
}
|
||||
},
|
||||
axisLabel: {
|
||||
color: contrastColor
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
type: 'dashed',
|
||||
color: '#aaa'
|
||||
}
|
||||
},
|
||||
splitArea: {
|
||||
areaStyle: {
|
||||
color: contrastColor
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
var colorPalette = [
|
||||
'#00a8c6',
|
||||
'#40c0cb',
|
||||
'#ebd3ad',
|
||||
'#aee239',
|
||||
'#8fbe00',
|
||||
'#33e0ff',
|
||||
'#b3f4ff',
|
||||
'#e6ff99'
|
||||
];
|
||||
var theme = {
|
||||
color: colorPalette,
|
||||
backgroundColor: '#333',
|
||||
tooltip: {
|
||||
axisPointer: {
|
||||
lineStyle: {
|
||||
color: contrastColor
|
||||
},
|
||||
crossStyle: {
|
||||
color: contrastColor
|
||||
}
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
textStyle: {
|
||||
color: contrastColor
|
||||
}
|
||||
},
|
||||
title: {
|
||||
textStyle: {
|
||||
color: contrastColor
|
||||
}
|
||||
},
|
||||
toolbox: {
|
||||
iconStyle: {
|
||||
borderColor: contrastColor
|
||||
}
|
||||
},
|
||||
|
||||
// Area scaling controller
|
||||
dataZoom: {
|
||||
dataBackgroundColor: '#eee', // Data background color
|
||||
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
|
||||
handleColor: '#00a8c6' // Handle color
|
||||
},
|
||||
|
||||
timeline: {
|
||||
itemStyle: {
|
||||
color: colorPalette[1]
|
||||
},
|
||||
lineStyle: {
|
||||
color: contrastColor
|
||||
},
|
||||
controlStyle: {
|
||||
color: contrastColor,
|
||||
borderColor: contrastColor
|
||||
},
|
||||
label: {
|
||||
color: contrastColor
|
||||
}
|
||||
},
|
||||
|
||||
timeAxis: axisCommon(),
|
||||
logAxis: axisCommon(),
|
||||
valueAxis: axisCommon(),
|
||||
categoryAxis: axisCommon(),
|
||||
|
||||
line: {
|
||||
symbol: 'circle'
|
||||
},
|
||||
graph: {
|
||||
color: colorPalette
|
||||
},
|
||||
|
||||
gauge: {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: [
|
||||
[0.2, '#40c0cb'],
|
||||
[0.8, '#00a8c6'],
|
||||
[1, '#8fbe00']
|
||||
],
|
||||
width: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
theme.categoryAxis.splitLine.show = false;
|
||||
echarts.registerTheme('dark-fresh-cut', theme);
|
||||
});
|
||||
@@ -1,164 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports', 'echarts'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports, require('echarts/lib/echarts'));
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({}, root.echarts);
|
||||
}
|
||||
})(this, function(exports, echarts) {
|
||||
var log = function(msg) {
|
||||
if (typeof console !== 'undefined') {
|
||||
console && console.error && console.error(msg);
|
||||
}
|
||||
};
|
||||
if (!echarts) {
|
||||
log('ECharts is not Loaded');
|
||||
return;
|
||||
}
|
||||
var contrastColor = '#eee';
|
||||
var axisCommon = function() {
|
||||
return {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: contrastColor
|
||||
}
|
||||
},
|
||||
axisTick: {
|
||||
lineStyle: {
|
||||
color: contrastColor
|
||||
}
|
||||
},
|
||||
axisLabel: {
|
||||
color: contrastColor
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
type: 'dashed',
|
||||
color: '#aaa'
|
||||
}
|
||||
},
|
||||
splitArea: {
|
||||
areaStyle: {
|
||||
color: contrastColor
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
var colorPalette = [
|
||||
'#cc0e00',
|
||||
'#ff1a0a',
|
||||
'#ff8880',
|
||||
'#ffc180',
|
||||
'#ffc2b0',
|
||||
'#ffffff',
|
||||
'#ff8880',
|
||||
'#ffe6e6'
|
||||
];
|
||||
var theme = {
|
||||
color: colorPalette,
|
||||
backgroundColor: '#333',
|
||||
tooltip: {
|
||||
axisPointer: {
|
||||
lineStyle: {
|
||||
color: contrastColor
|
||||
},
|
||||
crossStyle: {
|
||||
color: contrastColor
|
||||
}
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
textStyle: {
|
||||
color: contrastColor
|
||||
}
|
||||
},
|
||||
title: {
|
||||
textStyle: {
|
||||
color: contrastColor
|
||||
}
|
||||
},
|
||||
toolbox: {
|
||||
iconStyle: {
|
||||
borderColor: contrastColor
|
||||
}
|
||||
},
|
||||
|
||||
// Area scaling controller
|
||||
dataZoom: {
|
||||
dataBackgroundColor: '#eee', // Data background color
|
||||
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
|
||||
handleColor: '#cc0e00' // Handle color
|
||||
},
|
||||
|
||||
timeline: {
|
||||
itemStyle: {
|
||||
color: colorPalette[1]
|
||||
},
|
||||
lineStyle: {
|
||||
color: contrastColor
|
||||
},
|
||||
controlStyle: {
|
||||
color: contrastColor,
|
||||
borderColor: contrastColor
|
||||
},
|
||||
label: {
|
||||
color: contrastColor
|
||||
}
|
||||
},
|
||||
|
||||
timeAxis: axisCommon(),
|
||||
logAxis: axisCommon(),
|
||||
valueAxis: axisCommon(),
|
||||
categoryAxis: axisCommon(),
|
||||
|
||||
line: {
|
||||
symbol: 'circle'
|
||||
},
|
||||
graph: {
|
||||
color: colorPalette
|
||||
},
|
||||
|
||||
gauge: {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: [
|
||||
[0.2, '#ff1a0a'],
|
||||
[0.8, '#cc0e00'],
|
||||
[1, '#ffc2b0']
|
||||
],
|
||||
width: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
theme.categoryAxis.splitLine.show = false;
|
||||
echarts.registerTheme('dark-mushroom', theme);
|
||||
});
|
||||
@@ -1,224 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports', 'echarts'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports, require('echarts/lib/echarts'));
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({}, root.echarts);
|
||||
}
|
||||
})(this, function(exports, echarts) {
|
||||
var log = function(msg) {
|
||||
if (typeof console !== 'undefined') {
|
||||
console && console.error && console.error(msg);
|
||||
}
|
||||
};
|
||||
if (!echarts) {
|
||||
log('ECharts is not Loaded');
|
||||
return;
|
||||
}
|
||||
var contrastColor = '#B9B8CE';
|
||||
var backgroundColor = '#100C2A';
|
||||
var axisCommon = function () {
|
||||
return {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: contrastColor
|
||||
}
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: '#484753'
|
||||
}
|
||||
},
|
||||
splitArea: {
|
||||
areaStyle: {
|
||||
color: ['rgba(255,255,255,0.02)', 'rgba(255,255,255,0.05)']
|
||||
}
|
||||
},
|
||||
minorSplitLine: {
|
||||
lineStyle: {
|
||||
color: '#20203B'
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
var colorPalette = [
|
||||
'#4992ff',
|
||||
'#7cffb2',
|
||||
'#fddd60',
|
||||
'#ff6e76',
|
||||
'#58d9f9',
|
||||
'#05c091',
|
||||
'#ff8a45',
|
||||
'#8d48e3',
|
||||
'#dd79ff'
|
||||
];
|
||||
var theme = {
|
||||
darkMode: true,
|
||||
|
||||
color: colorPalette,
|
||||
backgroundColor: backgroundColor,
|
||||
axisPointer: {
|
||||
lineStyle: {
|
||||
color: '#817f91'
|
||||
},
|
||||
crossStyle: {
|
||||
color: '#817f91'
|
||||
},
|
||||
label: {
|
||||
// TODO Contrast of label backgorundColor
|
||||
color: '#fff'
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
textStyle: {
|
||||
color: contrastColor
|
||||
}
|
||||
},
|
||||
textStyle: {
|
||||
color: contrastColor
|
||||
},
|
||||
title: {
|
||||
textStyle: {
|
||||
color: '#EEF1FA'
|
||||
},
|
||||
subtextStyle: {
|
||||
color: '#B9B8CE'
|
||||
}
|
||||
},
|
||||
toolbox: {
|
||||
iconStyle: {
|
||||
borderColor: contrastColor
|
||||
}
|
||||
},
|
||||
dataZoom: {
|
||||
borderColor: '#71708A',
|
||||
textStyle: {
|
||||
color: contrastColor
|
||||
},
|
||||
brushStyle: {
|
||||
color: 'rgba(135,163,206,0.3)'
|
||||
},
|
||||
handleStyle: {
|
||||
color: '#353450',
|
||||
borderColor: '#C5CBE3'
|
||||
},
|
||||
moveHandleStyle: {
|
||||
color: '#B0B6C3',
|
||||
opacity: 0.3
|
||||
},
|
||||
fillerColor: 'rgba(135,163,206,0.2)',
|
||||
emphasis: {
|
||||
handleStyle: {
|
||||
borderColor: '#91B7F2',
|
||||
color: '#4D587D'
|
||||
},
|
||||
moveHandleStyle: {
|
||||
color: '#636D9A',
|
||||
opacity: 0.7
|
||||
}
|
||||
},
|
||||
dataBackground: {
|
||||
lineStyle: {
|
||||
color: '#71708A',
|
||||
width: 1
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#71708A'
|
||||
}
|
||||
},
|
||||
selectedDataBackground: {
|
||||
lineStyle: {
|
||||
color: '#87A3CE'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#87A3CE'
|
||||
}
|
||||
}
|
||||
},
|
||||
visualMap: {
|
||||
textStyle: {
|
||||
color: contrastColor
|
||||
}
|
||||
},
|
||||
timeline: {
|
||||
lineStyle: {
|
||||
color: contrastColor
|
||||
},
|
||||
label: {
|
||||
color: contrastColor
|
||||
},
|
||||
controlStyle: {
|
||||
color: contrastColor,
|
||||
borderColor: contrastColor
|
||||
}
|
||||
},
|
||||
calendar: {
|
||||
itemStyle: {
|
||||
color: backgroundColor
|
||||
},
|
||||
dayLabel: {
|
||||
color: contrastColor
|
||||
},
|
||||
monthLabel: {
|
||||
color: contrastColor
|
||||
},
|
||||
yearLabel: {
|
||||
color: contrastColor
|
||||
}
|
||||
},
|
||||
timeAxis: axisCommon(),
|
||||
logAxis: axisCommon(),
|
||||
valueAxis: axisCommon(),
|
||||
categoryAxis: axisCommon(),
|
||||
|
||||
line: {
|
||||
symbol: 'circle'
|
||||
},
|
||||
graph: {
|
||||
color: colorPalette
|
||||
},
|
||||
gauge: {
|
||||
title: {
|
||||
color: contrastColor
|
||||
}
|
||||
},
|
||||
candlestick: {
|
||||
itemStyle: {
|
||||
color: '#FD1050',
|
||||
color0: '#0CF49B',
|
||||
borderColor: '#FD1050',
|
||||
borderColor0: '#0CF49B'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
theme.categoryAxis.splitLine.show = false;
|
||||
echarts.registerTheme('dark', theme);
|
||||
});
|
||||
@@ -1,178 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports', 'echarts'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports, require('echarts/lib/echarts'));
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({}, root.echarts);
|
||||
}
|
||||
})(this, function(exports, echarts) {
|
||||
var log = function(msg) {
|
||||
if (typeof console !== 'undefined') {
|
||||
console && console.error && console.error(msg);
|
||||
}
|
||||
};
|
||||
if (!echarts) {
|
||||
log('ECharts is not Loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
var colorPalette = [
|
||||
'#59535e',
|
||||
'#e7dcef',
|
||||
'#f1baf3',
|
||||
'#5d4970',
|
||||
'#372049',
|
||||
'#c0b2cd',
|
||||
'#ffccff',
|
||||
'#f2f0f5'
|
||||
];
|
||||
|
||||
var theme = {
|
||||
color: colorPalette,
|
||||
|
||||
title: {
|
||||
textStyle: {
|
||||
fontWeight: 'normal',
|
||||
color: '#59535e'
|
||||
}
|
||||
},
|
||||
|
||||
visualMap: {
|
||||
color: ['#59535e', '#e7dcef']
|
||||
},
|
||||
|
||||
toolbox: {
|
||||
color: ['#59535e', '#59535e', '#59535e', '#59535e']
|
||||
},
|
||||
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
axisPointer: {
|
||||
// Axis indicator, coordinate trigger effective
|
||||
type: 'line', // The default is a straight line: 'line' | 'shadow'
|
||||
lineStyle: {
|
||||
// Straight line indicator style settings
|
||||
color: '#59535e',
|
||||
type: 'dashed'
|
||||
},
|
||||
crossStyle: {
|
||||
color: '#59535e'
|
||||
},
|
||||
shadowStyle: {
|
||||
// Shadow indicator style settings
|
||||
color: 'rgba(200,200,200,0.3)'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Area scaling controller
|
||||
dataZoom: {
|
||||
dataBackgroundColor: '#eee', // Data background color
|
||||
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
|
||||
handleColor: '#59535e' // Handle color
|
||||
},
|
||||
|
||||
timeline: {
|
||||
lineStyle: {
|
||||
color: '#59535e'
|
||||
},
|
||||
controlStyle: {
|
||||
color: '#59535e',
|
||||
borderColor: '#59535e'
|
||||
}
|
||||
},
|
||||
|
||||
candlestick: {
|
||||
itemStyle: {
|
||||
color: '#e7dcef',
|
||||
color0: '#f1baf3'
|
||||
},
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: '#372049',
|
||||
color0: '#5d4970'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#59535e',
|
||||
color0: '#e7dcef'
|
||||
}
|
||||
},
|
||||
|
||||
chord: {
|
||||
padding: 4,
|
||||
itemStyle: {
|
||||
color: '#59535e',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(128, 128, 128, 0.5)'
|
||||
},
|
||||
lineStyle: {
|
||||
color: 'rgba(128, 128, 128, 0.5)'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#e7dcef'
|
||||
}
|
||||
},
|
||||
|
||||
map: {
|
||||
itemStyle: {
|
||||
color: '#ddd'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#f1baf3'
|
||||
},
|
||||
label: {
|
||||
color: '#c12e34'
|
||||
}
|
||||
},
|
||||
|
||||
graph: {
|
||||
itemStyle: {
|
||||
color: '#59535e'
|
||||
},
|
||||
linkStyle: {
|
||||
color: '#59535e'
|
||||
}
|
||||
},
|
||||
|
||||
gauge: {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: [
|
||||
[0.2, '#e7dcef'],
|
||||
[0.8, '#59535e'],
|
||||
[1, '#372049']
|
||||
],
|
||||
width: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
echarts.registerTheme('eduardo', theme);
|
||||
});
|
||||
@@ -1,163 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports', 'echarts'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports, require('echarts/lib/echarts'));
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({}, root.echarts);
|
||||
}
|
||||
})(this, function(exports, echarts) {
|
||||
var log = function(msg) {
|
||||
if (typeof console !== 'undefined') {
|
||||
console && console.error && console.error(msg);
|
||||
}
|
||||
};
|
||||
if (!echarts) {
|
||||
log('ECharts is not Loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
var colorPalette = [
|
||||
'#313b23',
|
||||
'#494f2b',
|
||||
'#606233',
|
||||
'#d6b77b',
|
||||
'#0e0e0e',
|
||||
'#076278',
|
||||
'#808080',
|
||||
'#e7d5b1'
|
||||
];
|
||||
|
||||
var theme = {
|
||||
color: colorPalette,
|
||||
|
||||
title: {
|
||||
textStyle: {
|
||||
fontWeight: 'normal',
|
||||
color: '#313b23'
|
||||
}
|
||||
},
|
||||
|
||||
visualMap: {
|
||||
color: ['#313b23', '#494f2b']
|
||||
},
|
||||
|
||||
toolbox: {
|
||||
color: ['#313b23', '#313b23', '#313b23', '#313b23']
|
||||
},
|
||||
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
axisPointer: {
|
||||
// Axis indicator, coordinate trigger effective
|
||||
type: 'line', // The default is a straight line: 'line' | 'shadow'
|
||||
lineStyle: {
|
||||
// Straight line indicator style settings
|
||||
color: '#313b23',
|
||||
type: 'dashed'
|
||||
},
|
||||
crossStyle: {
|
||||
color: '#313b23'
|
||||
},
|
||||
shadowStyle: {
|
||||
// Shadow indicator style settings
|
||||
color: 'rgba(200,200,200,0.3)'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Area scaling controller
|
||||
dataZoom: {
|
||||
dataBackgroundColor: '#eee', // Data background color
|
||||
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
|
||||
handleColor: '#313b23' // Handle color
|
||||
},
|
||||
|
||||
timeline: {
|
||||
lineStyle: {
|
||||
color: '#313b23'
|
||||
},
|
||||
controlStyle: {
|
||||
color: '#313b23',
|
||||
borderColor: '#313b23'
|
||||
}
|
||||
},
|
||||
|
||||
candlestick: {
|
||||
itemStyle: {
|
||||
color: '#494f2b',
|
||||
color0: '#606233'
|
||||
},
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: '#0e0e0e',
|
||||
color0: '#d6b77b'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#494f2b',
|
||||
color0: '#d6b77b'
|
||||
}
|
||||
},
|
||||
|
||||
map: {
|
||||
itemStyle: {
|
||||
color: '#606233'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#ddd'
|
||||
},
|
||||
label: {
|
||||
color: '#c12e34'
|
||||
}
|
||||
},
|
||||
|
||||
graph: {
|
||||
itemStyle: {
|
||||
color: '#494f2b'
|
||||
},
|
||||
linkStyle: {
|
||||
color: '#313b23'
|
||||
}
|
||||
},
|
||||
|
||||
gauge: {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: [
|
||||
[0.2, '#494f2b'],
|
||||
[0.8, '#313b23'],
|
||||
[1, '0e0e0e']
|
||||
],
|
||||
width: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
echarts.registerTheme('forest', theme);
|
||||
});
|
||||
@@ -1,163 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports', 'echarts'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports, require('echarts/lib/echarts'));
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({}, root.echarts);
|
||||
}
|
||||
})(this, function(exports, echarts) {
|
||||
var log = function(msg) {
|
||||
if (typeof console !== 'undefined') {
|
||||
console && console.error && console.error(msg);
|
||||
}
|
||||
};
|
||||
if (!echarts) {
|
||||
log('ECharts is not Loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
var colorPalette = [
|
||||
'#00a8c6',
|
||||
'#40c0cb',
|
||||
'#f0dec2',
|
||||
'#aee239',
|
||||
'#8fbe00',
|
||||
'#33e0ff',
|
||||
'#b3f4ff',
|
||||
'#e6ff99'
|
||||
];
|
||||
|
||||
var theme = {
|
||||
color: colorPalette,
|
||||
|
||||
title: {
|
||||
textStyle: {
|
||||
fontWeight: 'normal',
|
||||
color: '#00a8c6'
|
||||
}
|
||||
},
|
||||
|
||||
visualMap: {
|
||||
color: ['#00a8c6', '#a2d4e6']
|
||||
},
|
||||
|
||||
toolbox: {
|
||||
color: ['#00a8c6', '#00a8c6', '#00a8c6', '#00a8c6']
|
||||
},
|
||||
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
axisPointer: {
|
||||
// Axis indicator, coordinate trigger effective
|
||||
type: 'line', // The default is a straight line: 'line' | 'shadow'
|
||||
lineStyle: {
|
||||
// Straight line indicator style settings
|
||||
color: '#00a8c6',
|
||||
type: 'dashed'
|
||||
},
|
||||
crossStyle: {
|
||||
color: '#00a8c6'
|
||||
},
|
||||
shadowStyle: {
|
||||
// Shadow indicator style settings
|
||||
color: 'rgba(200,200,200,0.3)'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Area scaling controller
|
||||
dataZoom: {
|
||||
dataBackgroundColor: '#eee', // Data background color
|
||||
fillerColor: 'rgba(144,197,237,0.2)', // Fill the color
|
||||
handleColor: '#00a8c6' // Handle color
|
||||
},
|
||||
|
||||
timeline: {
|
||||
lineStyle: {
|
||||
color: '#00a8c6'
|
||||
},
|
||||
controlStyle: {
|
||||
color: '#00a8c6',
|
||||
borderColor: '#00a8c6'
|
||||
}
|
||||
},
|
||||
|
||||
candlestick: {
|
||||
itemStyle: {
|
||||
color: '#40c0cb',
|
||||
color0: '#f0dec2'
|
||||
},
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: '#8fbe00',
|
||||
color0: '#aee239'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#00a8c6',
|
||||
color0: '#aee239'
|
||||
}
|
||||
},
|
||||
|
||||
map: {
|
||||
itemStyle: {
|
||||
color: '#ddd'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#f0dec2'
|
||||
},
|
||||
label: {
|
||||
color: '#c12e34'
|
||||
}
|
||||
},
|
||||
|
||||
graph: {
|
||||
itemStyle: {
|
||||
color: '#f0dec2'
|
||||
},
|
||||
linkStyle: {
|
||||
color: '#00a8c6'
|
||||
}
|
||||
},
|
||||
|
||||
gauge: {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: [
|
||||
[0.2, '#40c0cb'],
|
||||
[0.8, '#00a8c6'],
|
||||
[1, '#8fbe00']
|
||||
],
|
||||
width: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
echarts.registerTheme('fresh-cut', theme);
|
||||
});
|
||||
@@ -1,178 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports', 'echarts'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports, require('echarts/lib/echarts'));
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({}, root.echarts);
|
||||
}
|
||||
})(this, function(exports, echarts) {
|
||||
var log = function(msg) {
|
||||
if (typeof console !== 'undefined') {
|
||||
console && console.error && console.error(msg);
|
||||
}
|
||||
};
|
||||
if (!echarts) {
|
||||
log('ECharts is not Loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
var colorPalette = [
|
||||
'#ffcb6a',
|
||||
'#ffa850',
|
||||
'#ffe2c4',
|
||||
'#e5834e',
|
||||
'#ffb081',
|
||||
'#f7826e',
|
||||
'#faac9e',
|
||||
'#fcd5cf'
|
||||
];
|
||||
|
||||
var theme = {
|
||||
color: colorPalette,
|
||||
|
||||
title: {
|
||||
textStyle: {
|
||||
fontWeight: 'normal',
|
||||
color: '#ffcb6a'
|
||||
}
|
||||
},
|
||||
|
||||
visualMap: {
|
||||
color: ['#ffcb6a', '#ffa850']
|
||||
},
|
||||
|
||||
toolbox: {
|
||||
color: ['#ffcb6a', '#ffcb6a', '#ffcb6a', '#ffcb6a']
|
||||
},
|
||||
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
axisPointer: {
|
||||
// Axis indicator, coordinate trigger effective
|
||||
type: 'line', // The default is a straight line: 'line' | 'shadow'
|
||||
lineStyle: {
|
||||
// Straight line indicator style settings
|
||||
color: '#ffcb6a',
|
||||
type: 'dashed'
|
||||
},
|
||||
crossStyle: {
|
||||
color: '#ffcb6a'
|
||||
},
|
||||
shadowStyle: {
|
||||
// Shadow indicator style settings
|
||||
color: 'rgba(200,200,200,0.3)'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Area scaling controller
|
||||
dataZoom: {
|
||||
dataBackgroundColor: '#eee', // Data background color
|
||||
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
|
||||
handleColor: '#ffcb6a' // Handle color
|
||||
},
|
||||
|
||||
timeline: {
|
||||
lineStyle: {
|
||||
color: '#ffcb6a'
|
||||
},
|
||||
controlStyle: {
|
||||
color: '#ffcb6a',
|
||||
borderColor: '#ffcb6a'
|
||||
}
|
||||
},
|
||||
|
||||
candlestick: {
|
||||
itemStyle: {
|
||||
color: '#ffa850',
|
||||
color0: '#ffe2c4'
|
||||
},
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: '#ffb081',
|
||||
color0: '#e5834e'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#e5834e',
|
||||
color0: '#fcd5cf'
|
||||
}
|
||||
},
|
||||
|
||||
chord: {
|
||||
padding: 4,
|
||||
itemStyle: {
|
||||
color: '#fcd5cf',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(128, 128, 128, 0.5)'
|
||||
},
|
||||
lineStyle: {
|
||||
color: 'rgba(128, 128, 128, 0.5)'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#e5834e'
|
||||
}
|
||||
},
|
||||
|
||||
map: {
|
||||
itemStyle: {
|
||||
color: '#ffe2c4'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#ddd'
|
||||
},
|
||||
label: {
|
||||
color: '#c12e34'
|
||||
}
|
||||
},
|
||||
|
||||
graph: {
|
||||
itemStyle: {
|
||||
color: '#f2385a'
|
||||
},
|
||||
linkStyle: {
|
||||
color: '#ffcb6a'
|
||||
}
|
||||
},
|
||||
|
||||
gauge: {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: [
|
||||
[0.2, '#ffa850'],
|
||||
[0.8, '#ffcb6a'],
|
||||
[1, '#ffb081']
|
||||
],
|
||||
width: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
echarts.registerTheme('fruit', theme);
|
||||
});
|
||||
@@ -1,220 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports', 'echarts'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports, require('echarts/lib/echarts'));
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({}, root.echarts);
|
||||
}
|
||||
})(this, function(exports, echarts) {
|
||||
var log = function(msg) {
|
||||
if (typeof console !== 'undefined') {
|
||||
console && console.error && console.error(msg);
|
||||
}
|
||||
};
|
||||
if (!echarts) {
|
||||
log('ECharts is not Loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
var colorPalette = [
|
||||
'#757575',
|
||||
'#c7c7c7',
|
||||
'#dadada',
|
||||
'#8b8b8b',
|
||||
'#b5b5b5',
|
||||
'#e9e9e9'
|
||||
];
|
||||
|
||||
var theme = {
|
||||
color: colorPalette,
|
||||
|
||||
title: {
|
||||
textStyle: {
|
||||
fontWeight: 'normal',
|
||||
color: '#757575'
|
||||
}
|
||||
},
|
||||
|
||||
dataRange: {
|
||||
color: ['#636363', '#dcdcdc']
|
||||
},
|
||||
|
||||
toolbox: {
|
||||
color: ['#757575', '#757575', '#757575', '#757575']
|
||||
},
|
||||
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
axisPointer: {
|
||||
// Axis indicator, coordinate trigger effective
|
||||
type: 'line', // The default is a straight line: 'line' | 'shadow'
|
||||
lineStyle: {
|
||||
// Straight line indicator style settings
|
||||
color: '#757575',
|
||||
type: 'dashed'
|
||||
},
|
||||
crossStyle: {
|
||||
color: '#757575'
|
||||
},
|
||||
shadowStyle: {
|
||||
// Shadow indicator style settings
|
||||
color: 'rgba(200,200,200,0.3)'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Area scaling controller
|
||||
dataZoom: {
|
||||
dataBackgroundColor: '#eee', // Data background color
|
||||
fillerColor: 'rgba(117,117,117,0.2)', // Fill the color
|
||||
handleColor: '#757575' // Handle color
|
||||
},
|
||||
|
||||
grid: {
|
||||
borderWidth: 0
|
||||
},
|
||||
|
||||
categoryAxis: {
|
||||
axisLine: {
|
||||
// Coordinate axis
|
||||
lineStyle: {
|
||||
// Property 'lineStyle' controls line styles
|
||||
color: '#757575'
|
||||
}
|
||||
},
|
||||
splitLine: {
|
||||
// Separation line
|
||||
lineStyle: {
|
||||
// Property 'lineStyle' (see lineStyle) controls line styles
|
||||
color: ['#eee']
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
valueAxis: {
|
||||
axisLine: {
|
||||
// Coordinate axis
|
||||
lineStyle: {
|
||||
// Property 'lineStyle' controls line styles
|
||||
color: '#757575'
|
||||
}
|
||||
},
|
||||
splitArea: {
|
||||
show: true,
|
||||
areaStyle: {
|
||||
color: ['rgba(250,250,250,0.1)', 'rgba(200,200,200,0.1)']
|
||||
}
|
||||
},
|
||||
splitLine: {
|
||||
// Separation line
|
||||
lineStyle: {
|
||||
// Property 'lineStyle' (see lineStyle) controls line styles
|
||||
color: ['#eee']
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
timeline: {
|
||||
lineStyle: {
|
||||
color: '#757575'
|
||||
},
|
||||
controlStyle: {
|
||||
color: '#757575',
|
||||
borderColor: '#757575'
|
||||
}
|
||||
},
|
||||
|
||||
candlestick: {
|
||||
itemStyle: {
|
||||
color: '#8b8b8b',
|
||||
color0: '#dadada'
|
||||
},
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: '#757575',
|
||||
color0: '#c7c7c7'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#757575',
|
||||
color0: '#e9e9e9'
|
||||
}
|
||||
},
|
||||
|
||||
map: {
|
||||
itemStyle: {
|
||||
color: '#c7c7c7'
|
||||
},
|
||||
areaStyle: {
|
||||
color: 'ddd'
|
||||
},
|
||||
label: {
|
||||
color: '#c12e34'
|
||||
}
|
||||
},
|
||||
|
||||
graph: {
|
||||
itemStyle: {
|
||||
color: '#e9e9e9'
|
||||
},
|
||||
linkStyle: {
|
||||
color: '#757575'
|
||||
}
|
||||
},
|
||||
|
||||
chord: {
|
||||
padding: 4,
|
||||
itemStyle: {
|
||||
color: '#e9e9e9',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(128, 128, 128, 0.5)'
|
||||
},
|
||||
lineStyle: {
|
||||
color: 'rgba(128, 128, 128, 0.5)'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#757575'
|
||||
}
|
||||
},
|
||||
|
||||
gauge: {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: [
|
||||
[0.2, '#b5b5b5'],
|
||||
[0.8, '#757575'],
|
||||
[1, '#5c5c5c']
|
||||
],
|
||||
width: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
echarts.registerTheme('gray', theme);
|
||||
});
|
||||
@@ -1,222 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports', 'echarts'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports, require('echarts/lib/echarts'));
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({}, root.echarts);
|
||||
}
|
||||
})(this, function(exports, echarts) {
|
||||
var log = function(msg) {
|
||||
if (typeof console !== 'undefined') {
|
||||
console && console.error && console.error(msg);
|
||||
}
|
||||
};
|
||||
if (!echarts) {
|
||||
log('ECharts is not Loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
var colorPalette = [
|
||||
'#408829',
|
||||
'#68a54a',
|
||||
'#a9cba2',
|
||||
'#86b379',
|
||||
'#397b29',
|
||||
'#8abb6f',
|
||||
'#759c6a',
|
||||
'#bfd3b7'
|
||||
];
|
||||
|
||||
var theme = {
|
||||
color: colorPalette,
|
||||
|
||||
title: {
|
||||
textStyle: {
|
||||
fontWeight: 'normal',
|
||||
color: '#408829'
|
||||
}
|
||||
},
|
||||
|
||||
visualMap: {
|
||||
color: ['408829', '#a9cba2']
|
||||
},
|
||||
|
||||
toolbox: {
|
||||
color: ['#408829', '#408829', '#408829', '#408829']
|
||||
},
|
||||
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
axisPointer: {
|
||||
// Axis indicator, coordinate trigger effective
|
||||
type: 'line', // The default is a straight line: 'line' | 'shadow'
|
||||
lineStyle: {
|
||||
// Straight line indicator style settings
|
||||
color: '#408829',
|
||||
type: 'dashed'
|
||||
},
|
||||
crossStyle: {
|
||||
color: '#408829'
|
||||
},
|
||||
shadowStyle: {
|
||||
// Shadow indicator style settings
|
||||
color: 'rgba(200,200,200,0.3)'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Area scaling controller
|
||||
dataZoom: {
|
||||
dataBackgroundColor: '#eee', // Data background color
|
||||
fillerColor: 'rgba(64,136,41,0.2)', // Fill the color
|
||||
handleColor: '#408829' // Handle color
|
||||
},
|
||||
|
||||
grid: {
|
||||
borderWidth: 0
|
||||
},
|
||||
|
||||
categoryAxis: {
|
||||
axisLine: {
|
||||
// Coordinate axis
|
||||
lineStyle: {
|
||||
// Property 'lineStyle' controls line styles
|
||||
color: '#408829'
|
||||
}
|
||||
},
|
||||
splitLine: {
|
||||
// Separation line
|
||||
lineStyle: {
|
||||
// Property 'lineStyle' (see lineStyle) controls line styles
|
||||
color: ['#eee']
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
valueAxis: {
|
||||
axisLine: {
|
||||
// Coordinate axis
|
||||
lineStyle: {
|
||||
// Property 'lineStyle' controls line styles
|
||||
color: '#408829'
|
||||
}
|
||||
},
|
||||
splitArea: {
|
||||
show: true,
|
||||
areaStyle: {
|
||||
color: ['rgba(250,250,250,0.1)', 'rgba(200,200,200,0.1)']
|
||||
}
|
||||
},
|
||||
splitLine: {
|
||||
// Separation line
|
||||
lineStyle: {
|
||||
// Property 'lineStyle' (see lineStyle) controls line styles
|
||||
color: ['#eee']
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
timeline: {
|
||||
lineStyle: {
|
||||
color: '#408829'
|
||||
},
|
||||
controlStyle: {
|
||||
color: '#408829',
|
||||
borderColor: '#408829'
|
||||
}
|
||||
},
|
||||
|
||||
candlestick: {
|
||||
itemStyle: {
|
||||
color: '#68a54a',
|
||||
color0: '#a9cba2'
|
||||
},
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: '#408829',
|
||||
color0: '#86b379'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#408829',
|
||||
color0: '#bfd3b7'
|
||||
}
|
||||
},
|
||||
|
||||
graph: {
|
||||
itemStyle: {
|
||||
color: '#bfd3b7'
|
||||
},
|
||||
linkStyle: {
|
||||
color: '#408829'
|
||||
}
|
||||
},
|
||||
|
||||
chord: {
|
||||
padding: 4,
|
||||
itemStyle: {
|
||||
color: '#bfd3b7',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(128, 128, 128, 0.5)'
|
||||
},
|
||||
lineStyle: {
|
||||
color: 'rgba(128, 128, 128, 0.5)'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#408829'
|
||||
}
|
||||
},
|
||||
|
||||
map: {
|
||||
itemStyle: {
|
||||
color: '#ddd'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#408829'
|
||||
},
|
||||
label: {
|
||||
color: '#000'
|
||||
}
|
||||
},
|
||||
|
||||
gauge: {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: [
|
||||
[0.2, '#86b379'],
|
||||
[0.8, '#68a54a'],
|
||||
[1, '#408829']
|
||||
],
|
||||
width: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
echarts.registerTheme('green', theme);
|
||||
});
|
||||
@@ -1,263 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports', 'echarts'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports, require('echarts/lib/echarts'));
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({}, root.echarts);
|
||||
}
|
||||
})(this, function(exports, echarts) {
|
||||
var log = function(msg) {
|
||||
if (typeof console !== 'undefined') {
|
||||
console && console.error && console.error(msg);
|
||||
}
|
||||
};
|
||||
if (!echarts) {
|
||||
log('ECharts is not Loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
var colorPalette = [
|
||||
'#44B7D3',
|
||||
'#E42B6D',
|
||||
'#F4E24E',
|
||||
'#FE9616',
|
||||
'#8AED35',
|
||||
'#ff69b4',
|
||||
'#ba55d3',
|
||||
'#cd5c5c',
|
||||
'#ffa500',
|
||||
'#40e0d0',
|
||||
'#E95569',
|
||||
'#ff6347',
|
||||
'#7b68ee',
|
||||
'#00fa9a',
|
||||
'#ffd700',
|
||||
'#6699FF',
|
||||
'#ff6666',
|
||||
'#3cb371',
|
||||
'#b8860b',
|
||||
'#30e0e0'
|
||||
];
|
||||
|
||||
var theme = {
|
||||
color: colorPalette,
|
||||
|
||||
title: {
|
||||
textStyle: {
|
||||
fontWeight: 'normal',
|
||||
color: '#8A826D'
|
||||
}
|
||||
},
|
||||
|
||||
dataRange: {
|
||||
x: 'right',
|
||||
y: 'center',
|
||||
itemWidth: 5,
|
||||
itemHeight: 25,
|
||||
color: ['#E42B6D', '#F9AD96'],
|
||||
text: ['High', 'Low'], // Text, default is numeric text
|
||||
textStyle: {
|
||||
color: '#8A826D' // Range text color
|
||||
}
|
||||
},
|
||||
|
||||
toolbox: {
|
||||
color: ['#E95569', '#E95569', '#E95569', '#E95569'],
|
||||
effectiveColor: '#ff4500',
|
||||
itemGap: 8
|
||||
},
|
||||
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(138,130,109,0.7)', // Prompt background color, default is black with a transparency of 0.7
|
||||
axisPointer: {
|
||||
// Axis indicator, coordinate trigger effective
|
||||
type: 'line', // The default is a straight line: 'line' | 'shadow'
|
||||
lineStyle: {
|
||||
// Straight line indicator style settings
|
||||
color: '#6B6455',
|
||||
type: 'dashed'
|
||||
},
|
||||
crossStyle: {
|
||||
color: '#A6A299'
|
||||
},
|
||||
shadowStyle: {
|
||||
// Shadow indicator style settings
|
||||
color: 'rgba(200,200,200,0.3)'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Area scaling controller
|
||||
dataZoom: {
|
||||
dataBackgroundColor: 'rgba(130,197,209,0.6)', // Data background color
|
||||
fillerColor: 'rgba(233,84,105,0.1)', // Fill the color
|
||||
handleColor: 'rgba(107,99,84,0.8)' // Handle color
|
||||
},
|
||||
|
||||
grid: {
|
||||
borderWidth: 0
|
||||
},
|
||||
|
||||
categoryAxis: {
|
||||
axisLine: {
|
||||
// Coordinate axis
|
||||
lineStyle: {
|
||||
// Property 'lineStyle' controls line styles
|
||||
color: '#6B6455'
|
||||
}
|
||||
},
|
||||
splitLine: {
|
||||
// separate line
|
||||
show: false
|
||||
}
|
||||
},
|
||||
|
||||
valueAxis: {
|
||||
axisLine: {
|
||||
// Coordinate axis
|
||||
show: true
|
||||
},
|
||||
splitArea: {
|
||||
show: false
|
||||
},
|
||||
splitLine: {
|
||||
// separate line
|
||||
lineStyle: {
|
||||
// Property 'lineStyle' controls line styles
|
||||
color: ['#FFF'],
|
||||
type: 'dashed'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
polar: {
|
||||
axisLine: {
|
||||
// Coordinate axis
|
||||
lineStyle: {
|
||||
// // Property 'lineStyle' controls line styles
|
||||
color: '#ddd'
|
||||
}
|
||||
},
|
||||
splitArea: {
|
||||
show: true,
|
||||
areaStyle: {
|
||||
color: ['rgba(250,250,250,0.2)', 'rgba(200,200,200,0.2)']
|
||||
}
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: '#ddd'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
timeline: {
|
||||
lineStyle: {
|
||||
color: '#6B6455'
|
||||
},
|
||||
controlStyle: {
|
||||
color: '#6B6455',
|
||||
borderColor: '#6B6455'
|
||||
}
|
||||
},
|
||||
|
||||
line: {
|
||||
smooth: true,
|
||||
symbol: 'emptyCircle', // Inflection point graphic type
|
||||
symbolSize: 3 // Inflection point graphic size
|
||||
},
|
||||
|
||||
candlestick: {
|
||||
itemStyle: {
|
||||
color: '#e42B6d',
|
||||
color0: '#44B7d3'
|
||||
},
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: '#e42B6d',
|
||||
color0: '#44B7d3'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#fe994e',
|
||||
color0: '#e42B6d'
|
||||
}
|
||||
},
|
||||
|
||||
map: {
|
||||
itemStyle: {
|
||||
color: '#6b6455'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#ddd'
|
||||
},
|
||||
label: {
|
||||
color: '#e42B6d'
|
||||
}
|
||||
},
|
||||
|
||||
graph: {
|
||||
itemStyle: {
|
||||
color: '#e42B6d'
|
||||
},
|
||||
linkStyle: {
|
||||
color: '#6b6455'
|
||||
}
|
||||
},
|
||||
|
||||
chord: {
|
||||
padding: 4,
|
||||
itemStyle: {
|
||||
color: '#e42B6d',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(128, 128, 128, 0.5)'
|
||||
},
|
||||
lineStyle: {
|
||||
color: 'rgba(128, 128, 128, 0.5)'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#6b6455'
|
||||
}
|
||||
},
|
||||
|
||||
gauge: {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: [
|
||||
[0.2, '#44B7D3'],
|
||||
[0.8, '#6B6455'],
|
||||
[1, '#E42B6D']
|
||||
],
|
||||
width: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
echarts.registerTheme('helianthus', theme);
|
||||
});
|
||||
@@ -1,236 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports', 'echarts'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports, require('echarts/lib/echarts'));
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({}, root.echarts);
|
||||
}
|
||||
})(this, function(exports, echarts) {
|
||||
var log = function(msg) {
|
||||
if (typeof console !== 'undefined') {
|
||||
console && console.error && console.error(msg);
|
||||
}
|
||||
};
|
||||
if (!echarts) {
|
||||
log('ECharts is not Loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
var colorPalette = [
|
||||
'#C1232B',
|
||||
'#27727B',
|
||||
'#FCCE10',
|
||||
'#E87C25',
|
||||
'#B5C334',
|
||||
'#FE8463',
|
||||
'#9BCA63',
|
||||
'#FAD860',
|
||||
'#F3A43B',
|
||||
'#60C0DD',
|
||||
'#D7504B',
|
||||
'#C6E579',
|
||||
'#F4E001',
|
||||
'#F0805A',
|
||||
'#26C0C0'
|
||||
];
|
||||
|
||||
var theme = {
|
||||
color: colorPalette,
|
||||
|
||||
title: {
|
||||
textStyle: {
|
||||
fontWeight: 'normal',
|
||||
color: '#27727B'
|
||||
}
|
||||
},
|
||||
|
||||
visualMap: {
|
||||
color: ['#C1232B', '#FCCE10']
|
||||
},
|
||||
|
||||
toolbox: {
|
||||
iconStyle: {
|
||||
borderColor: colorPalette[0]
|
||||
}
|
||||
},
|
||||
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(50,50,50,0.5)',
|
||||
axisPointer: {
|
||||
type: 'line',
|
||||
lineStyle: {
|
||||
color: '#27727B',
|
||||
type: 'dashed'
|
||||
},
|
||||
crossStyle: {
|
||||
color: '#27727B'
|
||||
},
|
||||
shadowStyle: {
|
||||
color: 'rgba(200,200,200,0.3)'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
dataZoom: {
|
||||
dataBackgroundColor: 'rgba(181,195,52,0.3)',
|
||||
fillerColor: 'rgba(181,195,52,0.2)',
|
||||
handleColor: '#27727B'
|
||||
},
|
||||
|
||||
categoryAxis: {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: '#27727B'
|
||||
}
|
||||
},
|
||||
splitLine: {
|
||||
show: false
|
||||
}
|
||||
},
|
||||
|
||||
valueAxis: {
|
||||
axisLine: {
|
||||
show: false
|
||||
},
|
||||
splitArea: {
|
||||
show: false
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: ['#ccc'],
|
||||
type: 'dashed'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
timeline: {
|
||||
itemStyle: {
|
||||
color: '#27727B'
|
||||
},
|
||||
lineStyle: {
|
||||
color: '#27727B'
|
||||
},
|
||||
controlStyle: {
|
||||
color: '#27727B',
|
||||
borderColor: '#27727B'
|
||||
},
|
||||
symbol: 'emptyCircle',
|
||||
symbolSize: 3
|
||||
},
|
||||
|
||||
line: {
|
||||
itemStyle: {
|
||||
borderWidth: 2,
|
||||
borderColor: '#fff',
|
||||
lineStyle: {
|
||||
width: 3
|
||||
}
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
borderWidth: 0
|
||||
}
|
||||
},
|
||||
symbol: 'circle',
|
||||
symbolSize: 3.5
|
||||
},
|
||||
|
||||
candlestick: {
|
||||
itemStyle: {
|
||||
color: '#c1232b',
|
||||
color0: '#b5c334'
|
||||
},
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: '#c1232b',
|
||||
color0: '#b5c334'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#c1232b',
|
||||
color0: '#27727b'
|
||||
}
|
||||
},
|
||||
|
||||
graph: {
|
||||
itemStyle: {
|
||||
color: '#c1232b'
|
||||
},
|
||||
linkStyle: {
|
||||
color: '#b5c334'
|
||||
}
|
||||
},
|
||||
|
||||
map: {
|
||||
itemStyle: {
|
||||
color: '#f2385a',
|
||||
areaColor: '#ddd',
|
||||
borderColor: '#eee'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#fe994e'
|
||||
},
|
||||
label: {
|
||||
color: '#c1232b'
|
||||
}
|
||||
},
|
||||
|
||||
gauge: {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: [
|
||||
[0.2, '#B5C334'],
|
||||
[0.8, '#27727B'],
|
||||
[1, '#C1232B']
|
||||
]
|
||||
}
|
||||
},
|
||||
axisTick: {
|
||||
splitNumber: 2,
|
||||
length: 5,
|
||||
lineStyle: {
|
||||
color: '#fff'
|
||||
}
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#fff'
|
||||
},
|
||||
splitLine: {
|
||||
length: '5%',
|
||||
lineStyle: {
|
||||
color: '#fff'
|
||||
}
|
||||
},
|
||||
title: {
|
||||
offsetCenter: [0, -20]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
echarts.registerTheme('infographic', theme);
|
||||
});
|
||||
@@ -1,163 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports', 'echarts'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports, require('echarts/lib/echarts'));
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({}, root.echarts);
|
||||
}
|
||||
})(this, function(exports, echarts) {
|
||||
var log = function(msg) {
|
||||
if (typeof console !== 'undefined') {
|
||||
console && console.error && console.error(msg);
|
||||
}
|
||||
};
|
||||
if (!echarts) {
|
||||
log('ECharts is not Loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
var colorPalette = [
|
||||
'#cc0000',
|
||||
'#002266',
|
||||
'#ff9900',
|
||||
'#006600',
|
||||
'#8a150f',
|
||||
'#076278',
|
||||
'#808080',
|
||||
'#f07b75'
|
||||
];
|
||||
|
||||
var theme = {
|
||||
color: colorPalette,
|
||||
|
||||
title: {
|
||||
textStyle: {
|
||||
fontWeight: 'normal',
|
||||
color: '#cc0000'
|
||||
}
|
||||
},
|
||||
|
||||
visualMap: {
|
||||
color: ['#cc0000', '#002266']
|
||||
},
|
||||
|
||||
toolbox: {
|
||||
color: ['#cc0000', '#cc0000', '#cc0000', '#cc0000']
|
||||
},
|
||||
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
axisPointer: {
|
||||
// Axis indicator, coordinate trigger effective
|
||||
type: 'line', // The default is a straight line: 'line' | 'shadow'
|
||||
lineStyle: {
|
||||
// Straight line indicator style settings
|
||||
color: '#cc0000',
|
||||
type: 'dashed'
|
||||
},
|
||||
crossStyle: {
|
||||
color: '#cc0000'
|
||||
},
|
||||
shadowStyle: {
|
||||
// Shadow indicator style settings
|
||||
color: 'rgba(200,200,200,0.3)'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Area scaling controller
|
||||
dataZoom: {
|
||||
dataBackgroundColor: '#eee', // Data background color
|
||||
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
|
||||
handleColor: '#cc0000' // Handle color
|
||||
},
|
||||
|
||||
timeline: {
|
||||
lineStyle: {
|
||||
color: '#cc0000'
|
||||
},
|
||||
controlStyle: {
|
||||
color: '#cc0000',
|
||||
borderColor: '#cc0000'
|
||||
}
|
||||
},
|
||||
|
||||
candlestick: {
|
||||
itemStyle: {
|
||||
color: '#002266',
|
||||
color0: '#ff9900'
|
||||
},
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: '#8a150f',
|
||||
color0: '#006600'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#cc0000',
|
||||
color0: '#ff9900'
|
||||
}
|
||||
},
|
||||
|
||||
map: {
|
||||
itemStyle: {
|
||||
color: '#ff9900'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#ddd'
|
||||
},
|
||||
label: {
|
||||
color: '#c12e34'
|
||||
}
|
||||
},
|
||||
|
||||
graph: {
|
||||
itemStyle: {
|
||||
color: '#ff9900'
|
||||
},
|
||||
linkStyle: {
|
||||
color: '#cc0000'
|
||||
}
|
||||
},
|
||||
|
||||
gauge: {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: [
|
||||
[0.2, '#002266'],
|
||||
[0.8, '#cc0000'],
|
||||
[1, '8a150f']
|
||||
],
|
||||
width: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
echarts.registerTheme('inspired', theme);
|
||||
});
|
||||
@@ -1,163 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports', 'echarts'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports, require('echarts/lib/echarts'));
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({}, root.echarts);
|
||||
}
|
||||
})(this, function(exports, echarts) {
|
||||
var log = function(msg) {
|
||||
if (typeof console !== 'undefined') {
|
||||
console && console.error && console.error(msg);
|
||||
}
|
||||
};
|
||||
if (!echarts) {
|
||||
log('ECharts is not Loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
var colorPalette = [
|
||||
'#e9e0d1',
|
||||
'#91a398',
|
||||
'#33605a',
|
||||
'#070001',
|
||||
'#68462b',
|
||||
'#58a79c',
|
||||
'#abd3ce',
|
||||
'#eef6f5'
|
||||
];
|
||||
|
||||
var theme = {
|
||||
color: colorPalette,
|
||||
|
||||
title: {
|
||||
textStyle: {
|
||||
fontWeight: 'normal',
|
||||
color: '#e9e0d1'
|
||||
}
|
||||
},
|
||||
|
||||
visualMap: {
|
||||
color: ['#e9e0d1', '#91a398']
|
||||
},
|
||||
|
||||
toolbox: {
|
||||
color: ['#e9e0d1', '#e9e0d1', '#e9e0d1', '#e9e0d1']
|
||||
},
|
||||
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
axisPointer: {
|
||||
// Axis indicator, coordinate trigger effective
|
||||
type: 'line', // The default is a straight line: 'line' | 'shadow'
|
||||
lineStyle: {
|
||||
// Straight line indicator style settings
|
||||
color: '#e9e0d1',
|
||||
type: 'dashed'
|
||||
},
|
||||
crossStyle: {
|
||||
color: '#e9e0d1'
|
||||
},
|
||||
shadowStyle: {
|
||||
// Shadow indicator style settings
|
||||
color: 'rgba(200,200,200,0.3)'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Area scaling controller
|
||||
dataZoom: {
|
||||
dataBackgroundColor: '#eee', // Data background color
|
||||
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
|
||||
handleColor: '#e9e0d1' // Handle color
|
||||
},
|
||||
|
||||
timeline: {
|
||||
lineStyle: {
|
||||
color: '#e9e0d1'
|
||||
},
|
||||
controlStyle: {
|
||||
color: '#e9e0d1',
|
||||
borderColor: '#e9e0d1'
|
||||
}
|
||||
},
|
||||
|
||||
candlestick: {
|
||||
itemStyle: {
|
||||
color: '#91a398',
|
||||
color0: '#33605a'
|
||||
},
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: '#68462b',
|
||||
color0: '#070001'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#91a398',
|
||||
color0: '#abd3ce'
|
||||
}
|
||||
},
|
||||
|
||||
map: {
|
||||
itemStyle: {
|
||||
color: '#c12e34'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#ddd'
|
||||
},
|
||||
label: {
|
||||
color: '#c12e34'
|
||||
}
|
||||
},
|
||||
|
||||
graph: {
|
||||
itemStyle: {
|
||||
color: '#33605a'
|
||||
},
|
||||
linkStyle: {
|
||||
color: '#e9e0d1'
|
||||
}
|
||||
},
|
||||
|
||||
gauge: {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: [
|
||||
[0.2, '#91a398'],
|
||||
[0.8, '#e9e0d1'],
|
||||
[1, '#68462b']
|
||||
],
|
||||
width: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
echarts.registerTheme('jazz', theme);
|
||||
});
|
||||
@@ -1,163 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports', 'echarts'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports, require('echarts/lib/echarts'));
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({}, root.echarts);
|
||||
}
|
||||
})(this, function(exports, echarts) {
|
||||
var log = function(msg) {
|
||||
if (typeof console !== 'undefined') {
|
||||
console && console.error && console.error(msg);
|
||||
}
|
||||
};
|
||||
if (!echarts) {
|
||||
log('ECharts is not Loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
var colorPalette = [
|
||||
'#02151a',
|
||||
'#043a47',
|
||||
'#087891',
|
||||
'#c8c8c8',
|
||||
'#b31d14',
|
||||
'#0b9cc1',
|
||||
'#f2f2f2',
|
||||
'#f07b75'
|
||||
];
|
||||
|
||||
var theme = {
|
||||
color: colorPalette,
|
||||
|
||||
title: {
|
||||
textStyle: {
|
||||
fontWeight: 'normal',
|
||||
color: '#02151a'
|
||||
}
|
||||
},
|
||||
|
||||
visualMap: {
|
||||
color: ['#02151a', '#a2d4e6']
|
||||
},
|
||||
|
||||
toolbox: {
|
||||
color: ['#02151a', '#02151a', '#02151a', '#02151a']
|
||||
},
|
||||
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
axisPointer: {
|
||||
// Axis indicator, coordinate trigger effective
|
||||
type: 'line', // The default is a straight line: 'line' | 'shadow'
|
||||
lineStyle: {
|
||||
// Straight line indicator style settings
|
||||
color: '#02151a',
|
||||
type: 'dashed'
|
||||
},
|
||||
crossStyle: {
|
||||
color: '#02151a'
|
||||
},
|
||||
shadowStyle: {
|
||||
// Shadow indicator style settings
|
||||
color: 'rgba(200,200,200,0.3)'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Area scaling controller
|
||||
dataZoom: {
|
||||
dataBackgroundColor: '#eee', // Data background color
|
||||
fillerColor: 'rgba(144,197,237,0.2)', // Fill the color
|
||||
handleColor: '#02151a' // Handle color
|
||||
},
|
||||
|
||||
timeline: {
|
||||
lineStyle: {
|
||||
color: '#02151a'
|
||||
},
|
||||
controlStyle: {
|
||||
color: '#02151a',
|
||||
borderColor: '#02151a'
|
||||
}
|
||||
},
|
||||
|
||||
candlestick: {
|
||||
itemStyle: {
|
||||
color: '#043a47',
|
||||
color0: '#087891'
|
||||
},
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: '#b31d14',
|
||||
color0: '#c8c8c8'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#087891',
|
||||
color0: '#c8c8c8'
|
||||
}
|
||||
},
|
||||
|
||||
map: {
|
||||
itemStyle: {
|
||||
color: '#ddd'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#087891'
|
||||
},
|
||||
label: {
|
||||
color: '#c12e34'
|
||||
}
|
||||
},
|
||||
|
||||
graph: {
|
||||
itemStyle: {
|
||||
color: '#c12e34'
|
||||
},
|
||||
linkStyle: {
|
||||
color: '#02151a'
|
||||
}
|
||||
},
|
||||
|
||||
gauge: {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: [
|
||||
[0.2, '#043a47'],
|
||||
[0.8, '#02151a'],
|
||||
[1, '#b31d14']
|
||||
],
|
||||
width: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
echarts.registerTheme('london', theme);
|
||||
});
|
||||
@@ -1,242 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports', 'echarts'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports, require('echarts/lib/echarts'));
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({}, root.echarts);
|
||||
}
|
||||
})(this, function(exports, echarts) {
|
||||
var log = function(msg) {
|
||||
if (typeof console !== 'undefined') {
|
||||
console && console.error && console.error(msg);
|
||||
}
|
||||
};
|
||||
if (!echarts) {
|
||||
log('ECharts is not Loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
var colorPalette = [
|
||||
'#2ec7c9',
|
||||
'#b6a2de',
|
||||
'#5ab1ef',
|
||||
'#ffb980',
|
||||
'#d87a80',
|
||||
'#8d98b3',
|
||||
'#e5cf0d',
|
||||
'#97b552',
|
||||
'#95706d',
|
||||
'#dc69aa',
|
||||
'#07a2a4',
|
||||
'#9a7fd1',
|
||||
'#588dd5',
|
||||
'#f5994e',
|
||||
'#c05050',
|
||||
'#59678c',
|
||||
'#c9ab00',
|
||||
'#7eb00a',
|
||||
'#6f5553',
|
||||
'#c14089'
|
||||
];
|
||||
|
||||
var theme = {
|
||||
color: colorPalette,
|
||||
|
||||
title: {
|
||||
textStyle: {
|
||||
fontWeight: 'normal',
|
||||
color: '#008acd'
|
||||
}
|
||||
},
|
||||
|
||||
visualMap: {
|
||||
itemWidth: 15,
|
||||
color: ['#5ab1ef', '#e0ffff']
|
||||
},
|
||||
|
||||
toolbox: {
|
||||
iconStyle: {
|
||||
borderColor: colorPalette[0]
|
||||
}
|
||||
},
|
||||
|
||||
tooltip: {
|
||||
borderWidth: 0,
|
||||
backgroundColor: 'rgba(50,50,50,0.5)',
|
||||
textStyle: {
|
||||
color: '#FFF'
|
||||
},
|
||||
axisPointer: {
|
||||
type: 'line',
|
||||
lineStyle: {
|
||||
color: '#008acd'
|
||||
},
|
||||
crossStyle: {
|
||||
color: '#008acd'
|
||||
},
|
||||
shadowStyle: {
|
||||
color: 'rgba(200,200,200,0.2)'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
dataZoom: {
|
||||
dataBackgroundColor: '#efefff',
|
||||
fillerColor: 'rgba(182,162,222,0.2)',
|
||||
handleColor: '#008acd'
|
||||
},
|
||||
|
||||
grid: {
|
||||
borderColor: '#eee'
|
||||
},
|
||||
|
||||
categoryAxis: {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: '#008acd'
|
||||
}
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: ['#eee']
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
valueAxis: {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: '#008acd'
|
||||
}
|
||||
},
|
||||
splitArea: {
|
||||
show: true,
|
||||
areaStyle: {
|
||||
color: ['rgba(250,250,250,0.1)', 'rgba(200,200,200,0.1)']
|
||||
}
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: ['#eee']
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
timeline: {
|
||||
lineStyle: {
|
||||
color: '#008acd'
|
||||
},
|
||||
controlStyle: {
|
||||
color: '#008acd',
|
||||
borderColor: '#008acd'
|
||||
},
|
||||
symbol: 'emptyCircle',
|
||||
symbolSize: 3
|
||||
},
|
||||
|
||||
line: {
|
||||
smooth: true,
|
||||
symbol: 'emptyCircle',
|
||||
symbolSize: 3
|
||||
},
|
||||
|
||||
candlestick: {
|
||||
itemStyle: {
|
||||
color: '#d87a80',
|
||||
color0: '#2ec7c9'
|
||||
},
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: '#d87a80',
|
||||
color0: '#2ec7c9'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#2ec7c9',
|
||||
color0: '#b6a2de'
|
||||
}
|
||||
},
|
||||
|
||||
scatter: {
|
||||
symbol: 'circle',
|
||||
symbolSize: 4
|
||||
},
|
||||
|
||||
map: {
|
||||
itemStyle: {
|
||||
color: '#ddd'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#fe994e'
|
||||
},
|
||||
label: {
|
||||
color: '#d87a80'
|
||||
}
|
||||
},
|
||||
|
||||
graph: {
|
||||
itemStyle: {
|
||||
color: '#d87a80'
|
||||
},
|
||||
linkStyle: {
|
||||
color: '#2ec7c9'
|
||||
}
|
||||
},
|
||||
|
||||
gauge: {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: [
|
||||
[0.2, '#2ec7c9'],
|
||||
[0.8, '#5ab1ef'],
|
||||
[1, '#d87a80']
|
||||
],
|
||||
width: 10
|
||||
}
|
||||
},
|
||||
axisTick: {
|
||||
splitNumber: 10,
|
||||
length: 15,
|
||||
lineStyle: {
|
||||
color: 'auto'
|
||||
}
|
||||
},
|
||||
splitLine: {
|
||||
length: 22,
|
||||
lineStyle: {
|
||||
color: 'auto'
|
||||
}
|
||||
},
|
||||
pointer: {
|
||||
width: 5
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
echarts.registerTheme('macarons', theme);
|
||||
});
|
||||
@@ -1,246 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports', 'echarts'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports, require('echarts/lib/echarts'));
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({}, root.echarts);
|
||||
}
|
||||
})(this, function(exports, echarts) {
|
||||
var log = function(msg) {
|
||||
if (typeof console !== 'undefined') {
|
||||
console && console.error && console.error(msg);
|
||||
}
|
||||
};
|
||||
if (!echarts) {
|
||||
log('ECharts is not Loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
var colorPalette = [
|
||||
'#ed9678',
|
||||
'#e7dac9',
|
||||
'#cb8e85',
|
||||
'#f3f39d',
|
||||
'#c8e49c',
|
||||
'#f16d7a',
|
||||
'#f3d999',
|
||||
'#d3758f',
|
||||
'#dcc392',
|
||||
'#2e4783',
|
||||
'#82b6e9',
|
||||
'#ff6347',
|
||||
'#a092f1',
|
||||
'#0a915d',
|
||||
'#eaf889',
|
||||
'#6699FF',
|
||||
'#ff6666',
|
||||
'#3cb371',
|
||||
'#d5b158',
|
||||
'#38b6b6'
|
||||
];
|
||||
|
||||
var theme = {
|
||||
color: colorPalette,
|
||||
|
||||
title: {
|
||||
textStyle: {
|
||||
fontWeight: 'normal',
|
||||
color: '#cb8e85'
|
||||
}
|
||||
},
|
||||
|
||||
dataRange: {
|
||||
color: ['#cb8e85', '#e7dac9'], //颜色
|
||||
//text:['高','低'], // 文本,默认为数值文本
|
||||
textStyle: {
|
||||
color: '#333' // 值域文字颜色
|
||||
}
|
||||
},
|
||||
|
||||
bar: {
|
||||
barMinHeight: 0, // 最小高度改为0
|
||||
// barWidth: null, // 默认自适应
|
||||
barGap: '30%', // 柱间距离,默认为柱形宽度的30%,可设固定值
|
||||
barCategoryGap: '20%', // 类目间柱形距离,默认为类目间距的20%,可设固定值
|
||||
label: {
|
||||
show: false
|
||||
// position: 默认自适应,水平布局为'top',垂直布局为'right',可选为
|
||||
// 'inside'|'left'|'right'|'top'|'bottom'
|
||||
// textStyle: null // 默认使用全局文本样式,详见TEXTSTYLE
|
||||
},
|
||||
itemStyle: {
|
||||
// color: '各异',
|
||||
barBorderColor: '#fff', // 柱条边线
|
||||
barBorderRadius: 0, // 柱条边线圆角,单位px,默认为0
|
||||
barBorderWidth: 1 // 柱条边线线宽,单位px,默认为1
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
// color: '各异',
|
||||
barBorderColor: 'rgba(0,0,0,0)', // 柱条边线
|
||||
barBorderRadius: 0, // 柱条边线圆角,单位px,默认为0
|
||||
barBorderWidth: 1, // 柱条边线线宽,单位px,默认为1
|
||||
},
|
||||
label: {
|
||||
show: false
|
||||
// position: 默认自适应,水平布局为'top',垂直布局为'right',可选为
|
||||
// 'inside'|'left'|'right'|'top'|'bottom'
|
||||
// textStyle: null // 默认使用全局文本样式,详见TEXTSTYLE
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
line: {
|
||||
label: {
|
||||
show: false
|
||||
// position: 默认自适应,水平布局为'top',垂直布局为'right',可选为
|
||||
// 'inside'|'left'|'right'|'top'|'bottom'
|
||||
// textStyle: null // 默认使用全局文本样式,详见TEXTSTYLE
|
||||
},
|
||||
itemStyle: {
|
||||
// color: 各异,
|
||||
},
|
||||
emphasis: {
|
||||
// color: 各异,
|
||||
label: {
|
||||
show: false
|
||||
// position: 默认自适应,水平布局为'top',垂直布局为'right',可选为
|
||||
// 'inside'|'left'|'right'|'top'|'bottom'
|
||||
// textStyle: null // 默认使用全局文本样式,详见TEXTSTYLE
|
||||
}
|
||||
},
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
type: 'solid',
|
||||
shadowColor: 'rgba(0,0,0,0)', //默认透明
|
||||
shadowBlur: 5,
|
||||
shadowOffsetX: 3,
|
||||
shadowOffsetY: 3
|
||||
},
|
||||
//smooth : false,
|
||||
//symbol: null, // 拐点图形类型
|
||||
symbolSize: 2, // 拐点图形大小
|
||||
//symbolRotate : null, // 拐点图形旋转控制
|
||||
showAllSymbol: false // 标志图形默认只有主轴显示(随主轴标签间隔隐藏策略)
|
||||
},
|
||||
candlestick: {
|
||||
itemStyle: {
|
||||
color: '#fe9778',
|
||||
color0: '#e7dac9'
|
||||
},
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: '#f78766',
|
||||
color0: '#f1ccb8'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#e7dac9',
|
||||
color0: '#c8e49c'
|
||||
}
|
||||
},
|
||||
|
||||
// 饼图默认参数
|
||||
pie: {
|
||||
center: ['50%', '50%'], // 默认全局居中
|
||||
radius: [0, '75%'],
|
||||
clockWise: false, // 默认逆时针
|
||||
startAngle: 90,
|
||||
minAngle: 0, // 最小角度改为0
|
||||
selectedOffset: 10, // 选中是扇区偏移量
|
||||
label: {
|
||||
show: true,
|
||||
position: 'outer',
|
||||
color: '#1b1b1b',
|
||||
lineStyle: { color: '#1b1b1b' }
|
||||
// textStyle: null // 默认使用全局文本样式,详见TEXTSTYLE
|
||||
},
|
||||
itemStyle: {
|
||||
// color: 各异,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 1
|
||||
},
|
||||
labelLine: {
|
||||
show: true,
|
||||
length: 20,
|
||||
lineStyle: {
|
||||
// color: 各异,
|
||||
width: 1,
|
||||
type: 'solid'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
map: {
|
||||
itemStyle: {
|
||||
color: '#ddd',
|
||||
borderColor: '#fff',
|
||||
borderWidth: 1
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#f3f39d'
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
color: 'rgba(139,69,19,1)'
|
||||
},
|
||||
showLegendSymbol: true
|
||||
},
|
||||
|
||||
graph: {
|
||||
itemStyle: {
|
||||
color: '#d87a80'
|
||||
},
|
||||
linkStyle: {
|
||||
strokeColor: '#a17e6e'
|
||||
},
|
||||
nodeStyle: {
|
||||
brushType: 'both',
|
||||
strokeColor: '#a17e6e'
|
||||
},
|
||||
label: {
|
||||
show: false
|
||||
}
|
||||
},
|
||||
|
||||
gauge: {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: [
|
||||
[0.2, '#ed9678'],
|
||||
[0.8, '#e7dac9'],
|
||||
[1, '#cb8e85']
|
||||
],
|
||||
width: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
echarts.registerTheme('macarons2', theme);
|
||||
});
|
||||
@@ -1,155 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports', 'echarts'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports, require('echarts/lib/echarts'));
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({}, root.echarts);
|
||||
}
|
||||
})(this, function(exports, echarts) {
|
||||
var log = function(msg) {
|
||||
if (typeof console !== 'undefined') {
|
||||
console && console.error && console.error(msg);
|
||||
}
|
||||
};
|
||||
if (!echarts) {
|
||||
log('ECharts is not Loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
var colorPalette = [
|
||||
'#8aedd5',
|
||||
'#93bc9e',
|
||||
'#cef1db',
|
||||
'#7fe579',
|
||||
'#a6d7c2',
|
||||
'#bef0bb',
|
||||
'#99e2vb',
|
||||
'#94f8a8',
|
||||
'#7de5b8',
|
||||
'#4dfb70'
|
||||
];
|
||||
|
||||
var theme = {
|
||||
color: colorPalette,
|
||||
|
||||
title: {
|
||||
textStyle: {
|
||||
fontWeight: 'normal',
|
||||
color: '#8aedd5'
|
||||
}
|
||||
},
|
||||
|
||||
toolbox: {
|
||||
color: ['#8aedd5', '#8aedd5', '#8aedd5', '#8aedd5']
|
||||
},
|
||||
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
axisPointer: {
|
||||
// Axis indicator, coordinate trigger effective
|
||||
type: 'line', // The default is a straight line: 'line' | 'shadow'
|
||||
lineStyle: {
|
||||
// Straight line indicator style settings
|
||||
color: '#8aedd5',
|
||||
type: 'dashed'
|
||||
},
|
||||
crossStyle: {
|
||||
color: '#8aedd5'
|
||||
},
|
||||
shadowStyle: {
|
||||
// Shadow indicator style settings
|
||||
color: 'rgba(200,200,200,0.3)'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Area scaling controller
|
||||
dataZoom: {
|
||||
dataBackgroundColor: '#eee', // Data background color
|
||||
fillerColor: 'rgba(64,136,41,0.2)', // Fill the color
|
||||
handleColor: '#408829' // Handle color
|
||||
},
|
||||
|
||||
dataRange: {
|
||||
color: ['#93bc92', '#bef0bb']
|
||||
},
|
||||
|
||||
candlestick: {
|
||||
itemStyle: {
|
||||
color: '#8aedd5',
|
||||
color0: '#7fe579'
|
||||
},
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: '#8aedd5',
|
||||
color0: '#7fe579'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#8aedd5',
|
||||
color0: '#93bc9e'
|
||||
}
|
||||
},
|
||||
|
||||
graph: {
|
||||
itemStyle: {
|
||||
color: '#8aedd5'
|
||||
},
|
||||
linkStyle: {
|
||||
color: '#93bc9e'
|
||||
}
|
||||
},
|
||||
|
||||
map: {
|
||||
itemStyle: {
|
||||
color: '#8aedd5'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#93bc9e'
|
||||
},
|
||||
label: {
|
||||
color: '#cef1db'
|
||||
}
|
||||
},
|
||||
|
||||
gauge: {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: [
|
||||
[0.2, '#93bc9e'],
|
||||
[0.8, '#8aedd5'],
|
||||
[1, '#a6d7c2']
|
||||
],
|
||||
width: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
echarts.registerTheme('mint', theme);
|
||||
});
|
||||
@@ -1,163 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports', 'echarts'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports, require('echarts/lib/echarts'));
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({}, root.echarts);
|
||||
}
|
||||
})(this, function(exports, echarts) {
|
||||
var log = function(msg) {
|
||||
if (typeof console !== 'undefined') {
|
||||
console && console.error && console.error(msg);
|
||||
}
|
||||
};
|
||||
if (!echarts) {
|
||||
log('ECharts is not Loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
var colorPalette = [
|
||||
'#8b1a2d',
|
||||
'#a7314b',
|
||||
'#e6004c',
|
||||
'#ff8066',
|
||||
'#8e5c4e',
|
||||
'#ff1a66',
|
||||
'#d6c582',
|
||||
'#f0d4af'
|
||||
];
|
||||
|
||||
var theme = {
|
||||
color: colorPalette,
|
||||
|
||||
title: {
|
||||
textStyle: {
|
||||
fontWeight: 'normal',
|
||||
color: '#8b1a2d'
|
||||
}
|
||||
},
|
||||
|
||||
visualMap: {
|
||||
color: ['#8b1a2d', '#a7314b']
|
||||
},
|
||||
|
||||
toolbox: {
|
||||
color: ['#8b1a2d', '#8b1a2d', '#8b1a2d', '#8b1a2d']
|
||||
},
|
||||
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
axisPointer: {
|
||||
// Axis indicator, coordinate trigger effective
|
||||
type: 'line', // The default is a straight line: 'line' | 'shadow'
|
||||
lineStyle: {
|
||||
// Straight line indicator style settings
|
||||
color: '#8b1a2d',
|
||||
type: 'dashed'
|
||||
},
|
||||
crossStyle: {
|
||||
color: '#8b1a2d'
|
||||
},
|
||||
shadowStyle: {
|
||||
// Shadow indicator style settings
|
||||
color: 'rgba(200,200,200,0.3)'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Area scaling controller
|
||||
dataZoom: {
|
||||
dataBackgroundColor: '#eee', // Data background color
|
||||
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
|
||||
handleColor: '#8b1a2d' // Handle color
|
||||
},
|
||||
|
||||
timeline: {
|
||||
lineStyle: {
|
||||
color: '#8b1a2d'
|
||||
},
|
||||
controlStyle: {
|
||||
color: '#8b1a2d',
|
||||
borderColor: '#8b1a2d'
|
||||
}
|
||||
},
|
||||
|
||||
candlestick: {
|
||||
itemStyle: {
|
||||
color: '#a7314b',
|
||||
color0: '#d6c582'
|
||||
},
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: '#8e5c4e',
|
||||
color0: '#f0d4af'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#8b1a2d',
|
||||
color0: '#ff8066'
|
||||
}
|
||||
},
|
||||
|
||||
map: {
|
||||
itemStyle: {
|
||||
color: '#8b1a2d'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#ff8066'
|
||||
},
|
||||
label: {
|
||||
color: '#c12e34'
|
||||
}
|
||||
},
|
||||
|
||||
graph: {
|
||||
itemStyle: {
|
||||
color: '#ff8066'
|
||||
},
|
||||
linkStyle: {
|
||||
color: '#8b1a2d'
|
||||
}
|
||||
},
|
||||
|
||||
gauge: {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: [
|
||||
[0.2, '#a7314b'],
|
||||
[0.8, '#8b1a2d'],
|
||||
[1, '#8e5c4e']
|
||||
],
|
||||
width: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
echarts.registerTheme('red-velvet', theme);
|
||||
});
|
||||
@@ -1,225 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports', 'echarts'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports, require('echarts/lib/echarts'));
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({}, root.echarts);
|
||||
}
|
||||
})(this, function(exports, echarts) {
|
||||
var log = function(msg) {
|
||||
if (typeof console !== 'undefined') {
|
||||
console && console.error && console.error(msg);
|
||||
}
|
||||
};
|
||||
if (!echarts) {
|
||||
log('ECharts is not Loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
var colorPalette = [
|
||||
'#d8361b',
|
||||
'#f16b4c',
|
||||
'#f7b4a9',
|
||||
'#d26666',
|
||||
'#99311c',
|
||||
'#c42703',
|
||||
'#d07e75'
|
||||
];
|
||||
|
||||
var theme = {
|
||||
color: colorPalette,
|
||||
|
||||
title: {
|
||||
textStyle: {
|
||||
fontWeight: 'normal',
|
||||
color: '#d8361b'
|
||||
}
|
||||
},
|
||||
|
||||
visualMap: {
|
||||
color: ['#d8361b', '#ffd2d2']
|
||||
},
|
||||
|
||||
dataRange: {
|
||||
color: ['#bd0707', '#ffd2d2']
|
||||
},
|
||||
|
||||
toolbox: {
|
||||
color: ['#d8361b', '#d8361b', '#d8361b', '#d8361b']
|
||||
},
|
||||
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
axisPointer: {
|
||||
// Axis indicator, coordinate trigger effective
|
||||
type: 'line', // The default is a straight line: 'line' | 'shadow'
|
||||
lineStyle: {
|
||||
// Straight line indicator style settings
|
||||
color: '#d8361b',
|
||||
type: 'dashed'
|
||||
},
|
||||
crossStyle: {
|
||||
color: '#d8361b'
|
||||
},
|
||||
shadowStyle: {
|
||||
// Shadow indicator style settings
|
||||
color: 'rgba(200,200,200,0.3)'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Area scaling controller
|
||||
dataZoom: {
|
||||
dataBackgroundColor: '#eee', // Data background color
|
||||
fillerColor: 'rgba(216,54,27,0.2)', // Fill the color
|
||||
handleColor: '#d8361b' // Handle color
|
||||
},
|
||||
|
||||
grid: {
|
||||
borderWidth: 0
|
||||
},
|
||||
|
||||
categoryAxis: {
|
||||
axisLine: {
|
||||
// Coordinate axis
|
||||
lineStyle: {
|
||||
// Property 'lineStyle' controls line styles
|
||||
color: '#d8361b'
|
||||
}
|
||||
},
|
||||
splitLine: {
|
||||
// Separation line
|
||||
lineStyle: {
|
||||
// Property 'lineStyle' (see lineStyle) controls line styles
|
||||
color: ['#eee']
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
valueAxis: {
|
||||
axisLine: {
|
||||
// Coordinate axis
|
||||
lineStyle: {
|
||||
// Property 'lineStyle' controls line styles
|
||||
color: '#d8361b'
|
||||
}
|
||||
},
|
||||
splitArea: {
|
||||
show: true,
|
||||
areaStyle: {
|
||||
color: ['rgba(250,250,250,0.1)', 'rgba(200,200,200,0.1)']
|
||||
}
|
||||
},
|
||||
splitLine: {
|
||||
// Separation line
|
||||
lineStyle: {
|
||||
// Property 'lineStyle' (see lineStyle) controls line styles
|
||||
color: ['#eee']
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
timeline: {
|
||||
lineStyle: {
|
||||
color: '#d8361b'
|
||||
},
|
||||
controlStyle: {
|
||||
color: '#d8361b',
|
||||
borderColor: '#d8361b'
|
||||
}
|
||||
},
|
||||
|
||||
candlestick: {
|
||||
itemStyle: {
|
||||
color: '#f16b4c',
|
||||
color0: '#f7b4a9'
|
||||
},
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: '#d8361b',
|
||||
color0: '#d26666'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#d8361b',
|
||||
color0: '#d07e75'
|
||||
}
|
||||
},
|
||||
|
||||
graph: {
|
||||
itemStyle: {
|
||||
color: '#d07e75'
|
||||
},
|
||||
linkStyle: {
|
||||
color: '#d8361b'
|
||||
}
|
||||
},
|
||||
|
||||
chord: {
|
||||
padding: 4,
|
||||
itemStyle: {
|
||||
color: '#d07e75',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(128, 128, 128, 0.5)'
|
||||
},
|
||||
lineStyle: {
|
||||
color: 'rgba(128, 128, 128, 0.5)'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#d8361b'
|
||||
}
|
||||
},
|
||||
|
||||
map: {
|
||||
itemStyle: {
|
||||
color: '#d8361b'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#d07e75'
|
||||
},
|
||||
label: {
|
||||
color: '#c12e34'
|
||||
}
|
||||
},
|
||||
|
||||
gauge: {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: [
|
||||
[0.2, '#f16b4c'],
|
||||
[0.8, '#d8361b'],
|
||||
[1, '#99311c']
|
||||
],
|
||||
width: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
echarts.registerTheme('red', theme);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user