INNORIX
전송 빌더전송 파인더개발자리소스고객사
무료 시작하기
INNORIX

LET FILES
MOVE THEMSELVES

INNORIX는 모든 시스템과 환경에서 파일 이동과 자동화를 제공하는 엔터프라이즈 파일 인프라 기업입니다.
5,000개 이상의 기업 및 공공기관에서 사용하고 있습니다.

시작하기

  • 필요한 전송 만들기
  • 필요한 전송 찾기

주요 전송

  • 팀 업무 폴더 동기화
  • 고객에게 대용량 파일 전송
  • 여러 시스템 파일 탐색
  • FTP·SFTP·SCP·rsync 전환
  • 앱에 파일 전송 추가
  • 웹 업로드·다운로드 적용
  • AI·데이터 워크플로 구축
  • 모든 전송 둘러보기→

개발자

  • 개발자 센터
  • 구현 예제
  • API 빠른 시작
  • 개발자 가이드
  • API 레퍼런스
  • GitHub

리소스

  • 리소스 센터
  • 제품 가이드
  • 외부 서비스 연동
  • 배포 및 관리
  • 도움말 센터

고객사

  • 정부
  • 공공부문
  • 제조
  • 엔지니어링
  • 금융
  • 유통
  • IT/통신
  • 미디어
  • 의료
  • 교육

플랜

  • 가격 및 플랜

회사

INNORIX 소개

Exabyter를 찾고 계신가요?

이제 INNORIX Platform에 통합되었습니다

기타 INNORIX 제품

Al.bert — 스마트 교통 AI

글로벌 오피스

  • 미국 뉴욕
  • 대한민국 서울
  • 베트남 호찌민
  • 오피스 위치 보기→

(C)2026 INNORIX. All rights reserved.

  • 보안
  • 상태
  • 이용약관
  • 개인정보 처리방침
  • 쿠키 정책
  1. 개발자
  2. 구현 예제
  3. 여러 곳에서 파일 수집

여러 곳에서 파일 수집

INNORIX Transfer Builder를 사용해 다대1 파일 수집 흐름을 구성합니다. 여러 소스와 하나의 대상을 선택하고 수집 설정을 생성합니다.

배포 및 관리
Exabyter
  • 한 곳으로 파일 보내기
  • 여러 곳으로 파일 보내기
  • 여러 곳에서 파일 수집
  • 시스템 간 파일 동기화
  • 웹 업로드 · 다운로드

여러 곳에서 파일 수집은 여러 대의 디바이스에서 파일을 한 곳으로 수집합니다. 전 지점 마감 데이터를 본사로 모으기, 생산 라인별 로그를 분석 서버로 모으기 같은 경우입니다.

API 요청은 POST /api/automations 한 번입니다. details[] 항목마다 receiverId · targetPath 는 같게 두고 senderId · sourceItem 만 다르게 넣으면, 여러 곳의 파일이 한 곳으로 모입니다.

수집은 Repeat(매일·매주) 시작 조건과 함께 쓰이는 경우가 많고, 모이는 파일 이름이 겹치기 쉬우므로 Save Path(savepath · optionPath)와 Duplicated Name(target-action) 설정이 특히 중요합니다.

시작하기#

준비물#

  1. API Key — 제품 좌측 하단 프로필 메뉴 → Developer 에서 발급합니다. 같은 화면에 Workspace ID 도 함께 표시됩니다. API 로 발급하려면 POST /api/auth/api-keys (Bearer 액세스 토큰, 바디 없음) → data.apiKey.
  2. deviceId — 보내는 디바이스 N개와 받는 디바이스 1개. 제품의 Devices 에서 디바이스를 선택하면 우측 상단에 표시되는 ID 입니다.
  3. 경로 — 보낼 쪽 경로(sourceItem[].filePath)와 받을 쪽 경로(targetPath). 둘 다 슬래시(/)로 구분한 절대 경로를 사용합니다. targetPath 는 비어 있거나 / 이면 안 됩니다.

인증은 다음 두 가지 중 하나를 사용합니다.

http
x-api-key: <API Key>                  # long-lived key (recommended)
Authorization: Bearer <accessToken>   # short-lived token from login

워크스페이스를 명시해야 하는 경우에만 헤더를 하나 더 추가합니다. 이 헤더는 인증 수단이 아니라 대상 워크스페이스 지정용입니다.

http
x-workspace-id: <Workspace ID>        # optional

기본 주소는 https://app.innorix.com 입니다.

빠른 시작#

빌더에서 Get API Code 로 받은 번들을 그대로 실행하는 순서입니다.

  1. 전송 빌더에서 옵션을 고르고 Get API Code → 언어 선택 → zip 다운로드
  2. 압축을 풀고 .env 를 열어 INNORIX_API_KEY, SOURCE_IDS, TARGET_ID 와 경로(SOURCE_PATHS · TARGET_PATH)를 채웁니다
  3. 아래 명령으로 실행합니다
  4. 출력된 automationId 로 전송 상태를 조회합니다
언어 요구 사항 실행
Python Python 3.8+ pip install requests → python combo_builder.py
Node.js Node.js 18+ (의존성 없음) node combo_builder.js
Java JDK 11+ (의존성 없음) java ComboBuilder.java 또는 javac ComboBuilder.java && java ComboBuilder
C# .NET 8+ dotnet run

ℹ️ 위 요구 사항은 번들 예제 기준입니다. 이 문서에 실린 Java 발췌 코드는 가독성을 위해 텍스트 블록(""")을 사용하므로 JDK 17+ 가 필요합니다. 번들의 ComboBuilder.java 는 JDK 11+ 에서 동작합니다.

ℹ️ 번들의 combo_builder.* 는 같은 폴더의 .env 를 직접 읽습니다(별도 라이브러리 없이). 반면 이 문서에 실린 발췌 코드는 환경 변수에서 값을 읽으므로, 그대로 복사해 실행할 때는 아래처럼 값을 내보낸 뒤 실행하세요.

macOS · Linux

bash
export INNORIX_API_KEY=your-api-key
export SOURCE_IDS=branch-01,branch-02,branch-03
export SOURCE_PATHS=C:/out             # 1 entry = same for all, N = one per source
export TARGET_ID=device-target-01
export TARGET_PATH=D:/collected

Windows PowerShell (CMD 에서는 set INNORIX_API_KEY=your-api-key 형식)

powershell
$env:INNORIX_API_KEY="your-api-key"
$env:SOURCE_IDS="branch-01,branch-02,branch-03"
$env:SOURCE_PATHS="C:/out"
$env:TARGET_ID="device-target-01"
$env:TARGET_PATH="D:/collected"

전송 만들기#

전송 만들기#

지점 3곳에서 본사 서버 한 곳으로 모으는 요청입니다.

json
{
  "name": "branch-collect",
  "flowName": "branch-collect",
  "transferType": "normal",
  "timezone": "Asia/Seoul",
  "details": [
    {
      "senderId": "<branch-01>",
      "receiverId": "<hqDeviceId>",
      "sourceItem": [{ "filePath": "C:/out", "isDir": true }],
      "targetPath": "D:/collected",
      "step": 1,
      "transferOptions": {
        "noSchedule": false,
        "target-action": "numbering",
        "savepath": true,
        "optionPath": 2
      }
    },
    {
      "senderId": "<branch-02>",
      "receiverId": "<hqDeviceId>",
      "sourceItem": [{ "filePath": "C:/out", "isDir": true }],
      "targetPath": "D:/collected",
      "step": 1,
      "transferOptions": {
        "noSchedule": false,
        "target-action": "numbering",
        "savepath": true,
        "optionPath": 2
      }
    },
    {
      "senderId": "<branch-03>",
      "receiverId": "<hqDeviceId>",
      "sourceItem": [{ "filePath": "C:/out", "isDir": true }],
      "targetPath": "D:/collected",
      "step": 1,
      "transferOptions": {
        "noSchedule": false,
        "target-action": "numbering",
        "savepath": true,
        "optionPath": 2
      }
    }
  ],
  "schedules": [
    { "type": "day", "hour": "02", "minute": "00", "ampm": "am",
      "startDateType": "specific", "startDate": "2026-09-15T02:00:00", "timezone": "Asia/Seoul" }
  ],
  "step": 1,
  "isUpcoming": false
}
  • savepath 는 원본 폴더 구조 유지 여부이고, optionPath 는 그 아래 만들 하위 폴더 규칙입니다. 1 = 날짜(YYMMDD), 2 = 디바이스 이름, 3 = 사용자 지정 폴더(savepath 에 폴더명을 넣습니다). optionPath 는 단독으로 동작하지 않고 savepath 와 함께 보내야 합니다.
  • transferOptions.target-action 은 이름이 겹칠 때의 동작입니다. overwrite(덮어쓰기) · numbering(이름 뒤에 번호) · nosend(건너뛰기) 중 하나를 넣습니다.
  • startDate 는 예시 값입니다. 이 요청은 매일 02:00(Asia/Seoul) 반복이므로, 실제로는 반복을 시작할 날짜와 시각을 지정하세요.

위 요청의 optionPath: 2 는 디바이스 이름 하위 폴더를 만듭니다. 지점별로 D:/collected/branch-01/, D:/collected/branch-02/, D:/collected/branch-03/ 처럼 나뉘어 파일명 충돌이 사라집니다.

# pip install requests
import os, time, requests

BASE = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
HEADERS = {"x-api-key": os.environ["INNORIX_API_KEY"], "Content-Type": "application/json"}
# Every setting comes from an environment variable (second argument is the default).
TZ           = os.getenv("SCHEDULE_TZ", "Asia/Seoul")
SOURCE_IDS   = [x.strip() for x in os.getenv("SOURCE_IDS", "branch-01,branch-02,branch-03").split(",") if x.strip()]
SOURCE_PATHS = [x.strip() for x in os.getenv("SOURCE_PATHS", "C:/out").split(",") if x.strip()]
TARGET_ID    = os.environ["TARGET_ID"]
TARGET_PATH  = os.getenv("TARGET_PATH", "D:/collected")
# SOURCE_PATHS: 1 entry = same for every device, N = one per SOURCE_IDS entry

def now_iso():
    return time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime())

def call(method, path, body=None, params=None):
    r = requests.request(method, BASE + path, headers=HEADERS,
                         json=body, params=params, timeout=30)
    if not r.ok:
        raise RuntimeError(f"API {r.status_code}: {r.text[:500]}")
    return (r.json() or {}).get("data")

def expand(paths, count):
    if len(paths) == 1:
        return paths * count
    if len(paths) == count:
        return paths
    raise ValueError(f"SOURCE_PATHS must have 1 entry or exactly {count}")

# Collecting hits name conflicts often - a device-name subfolder (optionPath=2) plus rename is recommended.
options = {
    "noSchedule": False,
    "target-action": "numbering",      # Skip=nosend, Overwrite=overwrite
    "savepath": True,
    "optionPath": 2,                   # 1=date (YYMMDD), 2=device name, 3=custom
}

paths = expand(SOURCE_PATHS, len(SOURCE_IDS))
details = [{
    "senderId": source_id,
    "receiverId": TARGET_ID,
    "sourceItem": [{"filePath": paths[i], "isDir": True}],
    "targetPath": TARGET_PATH,
    "step": 1,
    "transferOptions": options,
} for i, source_id in enumerate(SOURCE_IDS)]

body = {
    "name": "branch-collect",
    "flowName": "branch-collect",
    "transferType": "normal",
    "timezone": TZ,
    "details": details,
    # Repeats daily at 02:00 - switch this to a "now" schedule to run immediately.
    "schedules": [{
        "type": "day", "hour": "02", "minute": "00", "ampm": "am",
        "startDateType": "specific", "startDate": "2026-09-15T02:00:00", "timezone": TZ,
    }],
    "step": 1,
    "isUpcoming": False,
}

automation_id = call("POST", "/api/automations", body)["automationId"]
print(f"automation created: {automation_id}  ({len(details)} sources)")
// JDK 17+ (no external dependencies)
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Arrays;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

public class CollectFromMany {

    static final String BASE = env("INNORIX_BASE_URL", "https://app.innorix.com");
    static final String API_KEY = env("INNORIX_API_KEY", "");
    static final HttpClient HTTP = HttpClient.newHttpClient();

    // Every setting comes from an environment variable (second argument is the default).
    static final List<String> SOURCE_IDS = envList("SOURCE_IDS", "branch-01,branch-02,branch-03");
    static final List<String> SOURCE_PATHS = envList("SOURCE_PATHS", "C:/out");  // 1 = same for all, N = one each
    static final String TARGET_PATH = env("TARGET_PATH", "D:/collected");

    /** Reads an environment variable, falling back to the default when it is empty. */
    static String env(String key, String dflt) {
        String v = System.getenv(key);
        return (v == null || v.isBlank()) ? dflt : v;
    }

    /** Splits a comma-separated environment variable into a list. */
    static List<String> envList(String key, String dflt) {
        return Arrays.stream(env(key, dflt).split(","))
                .map(String::trim).filter(x -> !x.isEmpty()).toList();
    }

    /** Extracts one string field from the response. Like the downloaded example, it needs no
     *  external JSON library. Use Jackson or Gson when you need to walk deeper structures. */
    static String jsonString(String json, String key) {
        Matcher m = Pattern.compile("\"" + key + "\"\\s*:\\s*\"([^\"]*)\"").matcher(json);
        return m.find() ? m.group(1) : null;
    }

    /** Extracts one integer field from the response. */
    static int jsonInt(String json, String key, int dflt) {
        Matcher m = Pattern.compile("\"" + key + "\"\\s*:\\s*(-?\\d+)").matcher(json);
        return m.find() ? Integer.parseInt(m.group(1)) : dflt;
    }

    static String call(String method, String path, String body) throws Exception {
        HttpRequest.BodyPublisher pub = body == null
                ? HttpRequest.BodyPublishers.noBody()
                : HttpRequest.BodyPublishers.ofString(body);
        HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + path))
                .header("Content-Type", "application/json")
                .header("x-api-key", API_KEY)
                .method(method, pub)
                .build();
        HttpResponse<String> res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
        if (res.statusCode() >= 400) throw new RuntimeException("API " + res.statusCode() + ": " + res.body());
        return res.body();
    }

    static List<String> expand(List<String> paths, int count) {
        if (paths.size() == 1) return IntStream.range(0, count).mapToObj(i -> paths.get(0)).toList();
        if (paths.size() == count) return paths;
        throw new IllegalArgumentException("SOURCE_PATHS must have 1 entry or exactly " + count);
    }

    public static void main(String[] args) throws Exception {
        String targetId = env("TARGET_ID", "");
        List<String> paths = expand(SOURCE_PATHS, SOURCE_IDS.size());

        // Collecting hits name conflicts often - device-name subfolder (optionPath=2) plus rename (numbering)
        String options = """
                { "noSchedule": false, "target-action": "numbering", "savepath": true, "optionPath": 2 }
                """;

        String details = IntStream.range(0, SOURCE_IDS.size())
                .mapToObj(i -> """
                        {
                          "senderId": "%s",
                          "receiverId": "%s",
                          "sourceItem": [{ "filePath": "%s", "isDir": true }],
                          "targetPath": "%s",
                          "step": 1,
                          "transferOptions": %s
                        }
                        """.formatted(SOURCE_IDS.get(i), targetId, paths.get(i), TARGET_PATH, options))
                .collect(Collectors.joining(","));

        String body = """
                {
                  "name": "branch-collect",
                  "flowName": "branch-collect",
                  "transferType": "normal",
                  "timezone": "Asia/Seoul",
                  "details": [%s],
                  "schedules": [{
                    "type": "day", "hour": "02", "minute": "00", "ampm": "am",
                    "startDateType": "specific", "startDate": "2026-09-15T02:00:00",
                    "timezone": "Asia/Seoul"
                  }],
                  "step": 1,
                  "isUpcoming": false
                }
                """.formatted(details);

        String res = call("POST", "/api/automations", body);
        System.out.println("automation created: " + jsonString(res, "automationId")
                + " (" + SOURCE_IDS.size() + " sources)");
    }
}
// Node.js 18+ (uses the built-in fetch)
const BASE = process.env.INNORIX_BASE_URL || 'https://app.innorix.com';
const HEADERS = {
  'x-api-key': process.env.INNORIX_API_KEY,
  'Content-Type': 'application/json',
};

// Every setting comes from an environment variable (second argument is the default).
const envList = (key, dflt) =>
  (process.env[key] || dflt).split(',').map((x) => x.trim()).filter(Boolean);

const TZ = process.env.SCHEDULE_TZ || 'Asia/Seoul';
const SOURCE_IDS = envList('SOURCE_IDS', 'branch-01,branch-02,branch-03');
const SOURCE_PATHS = envList('SOURCE_PATHS', 'C:/out');   // 1 = same for all, N = one each
const TARGET_ID = process.env.TARGET_ID;
const TARGET_PATH = process.env.TARGET_PATH || 'D:/collected';

async function call(method, path, body, params) {
  let url = BASE + path;
  if (params) url += '?' + new URLSearchParams(params).toString();
  const res = await fetch(url, {
    method,
    headers: HEADERS,
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const payload = await res.json().catch(() => ({}));
  if (!res.ok) throw new Error(`API ${res.status}: ${JSON.stringify(payload)}`);
  return payload.data;
}

function expand(paths, count) {
  if (paths.length === 1) return Array(count).fill(paths[0]);
  if (paths.length === count) return paths;
  throw new Error(`SOURCE_PATHS must have 1 entry or exactly ${count}`);
}

// Collecting hits name conflicts often - device-name subfolder (optionPath=2) plus rename (numbering)
const options = {
  noSchedule: false,
  'target-action': 'numbering',
  savepath: true,
  optionPath: 2,
};

const paths = expand(SOURCE_PATHS, SOURCE_IDS.length);
const details = SOURCE_IDS.map((senderId, i) => ({
  senderId,
  receiverId: TARGET_ID,
  sourceItem: [{ filePath: paths[i], isDir: true }],
  targetPath: TARGET_PATH,
  step: 1,
  transferOptions: options,
}));

const { automationId } = await call('POST', '/api/automations', {
  name: 'branch-collect',
  flowName: 'branch-collect',
  transferType: 'normal',
  timezone: TZ,
  details,
  schedules: [{
    type: 'day', hour: '02', minute: '00', ampm: 'am',
    startDateType: 'specific', startDate: '2026-09-15T02:00:00', timezone: TZ,
  }],
  step: 1,
  isUpcoming: false,
});

console.log(`automation created: ${automationId} (${details.length} sources)`);
// .NET 8+ (standard library only)
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;

static class CollectFromMany
{
    // Every setting comes from an environment variable (second argument is the default).
    static string Env(string key, string dflt = "") =>
        Environment.GetEnvironmentVariable(key) is { Length: > 0 } v ? v : dflt;

    static string[] EnvList(string key, string dflt) =>
        Env(key, dflt).Split(',').Select(x => x.Trim()).Where(x => x.Length > 0).ToArray();

    static readonly string Base = Env("INNORIX_BASE_URL", "https://app.innorix.com");
    static readonly HttpClient Http = new();

    static readonly string[] SourceIds = EnvList("SOURCE_IDS", "branch-01,branch-02,branch-03");
    static readonly string[] SourcePaths = EnvList("SOURCE_PATHS", "C:/out");  // 1 = same for all, N = one each
    static readonly string TargetPath = Env("TARGET_PATH", "D:/collected");

    static async Task<JsonElement> Call(string method, string path, object? body = null)
    {
        var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
        req.Headers.Add("x-api-key", Env("INNORIX_API_KEY"));
        if (body != null)
            req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");

        var res = await Http.SendAsync(req);
        var text = await res.Content.ReadAsStringAsync();
        if (!res.IsSuccessStatusCode) throw new Exception(
quot;API {(int)res.StatusCode}: {text}"
); return JsonDocument.Parse(text).RootElement.GetProperty("data"); } static string[] Expand(string[] paths, int count) { if (paths.Length == 1) return Enumerable.Repeat(paths[0], count).ToArray(); if (paths.Length == count) return paths; throw new ArgumentException(
quot;SourcePaths must have 1 entry or exactly {count}"
); } static async Task Main() { var tz = Env("SCHEDULE_TZ", "Asia/Seoul"); var targetId = Env("TARGET_ID"); var paths = Expand(SourcePaths, SourceIds.Length); // Collecting hits name conflicts often - device-name subfolder (optionPath=2) plus rename (numbering) var options = new Dictionary<string, object> { ["noSchedule"] = false, ["target-action"] = "numbering", ["savepath"] = true, ["optionPath"] = 2, }; var details = SourceIds.Select((senderId, i) => new { senderId, receiverId = targetId, sourceItem = new[] { new { filePath = paths[i], isDir = true } }, targetPath = TargetPath, step = 1, transferOptions = options, }).ToArray(); var data = await Call("POST", "/api/automations", new { name = "branch-collect", flowName = "branch-collect", transferType = "normal", timezone = tz, details, schedules = new[] { new { type = "day", hour = "02", minute = "00", ampm = "am", startDateType = "specific", startDate = "2026-09-15T02:00:00", timezone = tz, } }, step = 1, isUpcoming = false, }); Console.WriteLine(
quot;automation created: {data.GetProperty("automationId").GetString()} ({details.Length} sources)"
); } }

진행 상황 확인#

수집 대상이 N곳이면 전송도 N건입니다.

http
GET /api/transfers?automationId=<automationId>     -> rows in data.data[] whose type is not automation|history|flow
GET /api/transfers/<monitorId>                     → status, percent, isTerminal

반복(Repeat) 자동화는 실행할 때마다 새 전송이 생기므로, 조회 시점에 따라 목록이 비어 있을 수 있습니다. 지난 실행 결과는 자동화의 실행 로그에서 확인합니다.

SKIP_ROW_TYPES = {"automation", "history", "flow"}
TERMINAL = {2, 4, 5, 9, 99}
STATUS = {-1: "queued", 0: "waiting", 1: "started", 2: "complete", 3: "paused",
          4: "error", 5: "cancelled", 6: "transferring", 7: "skipped", 8: "retry",
          9: "partial-complete", 11: "virus-scanning", 12: "syncing", 99: "fail"}

def active_transfers(automation_id):
    result = call("GET", "/api/transfers", params={"automationId": automation_id})
    records = result.get("data") if isinstance(result, dict) else result
    return [r for r in (records or []) if r.get("type") not in SKIP_ROW_TYPES]

for row in active_transfers(automation_id):
    mid = row.get("monitorId") or row.get("id")
    detail = call("GET", f"/api/transfers/{mid}") or {}
    status = detail.get("status")
    print(f"  {mid}: {STATUS.get(status, status)} ({detail.get('percent', 0)}%)")
// Reuses call(), jsonString() and jsonInt() from the example above.
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

static final Set<Integer> TERMINAL = Set.of(2, 4, 5, 9, 99);

/** Collects every monitorId in the list response.
 *  Summary rows (type=automation/history/flow) carry no monitorId, so they drop out. */
static List<String> monitorIds(String automationId) throws Exception {
    String json = call("GET", "/api/transfers?automationId=" + automationId, null);
    List<String> ids = new ArrayList<>();
    Matcher m = Pattern.compile("\"monitorId\"\\s*:\\s*\"([^\"]+)\"").matcher(json);
    while (m.find()) if (!ids.contains(m.group(1))) ids.add(m.group(1));
    return ids;
}

/** Polls every 3 seconds until the transfer reaches a terminal status. */
static int waitFor(String monitorId, int timeoutSeconds) throws Exception {
    long deadline = System.currentTimeMillis() + timeoutSeconds * 1000L;
    while (System.currentTimeMillis() < deadline) {
        String json = call("GET", "/api/transfers/" + monitorId, null);
        int status = jsonInt(json, "status", -1);
        System.out.println("  " + monitorId + ": status=" + status
                + " (" + jsonInt(json, "percent", 0) + "%)");
        if (TERMINAL.contains(status)) return status;
        Thread.sleep(3000);
    }
    throw new RuntimeException(monitorId + " did not finish within " + timeoutSeconds + "s");
}

// Usage - a repeating automation shows a transfer only while it is running.
for (String monitorId : monitorIds(automationId)) waitFor(monitorId, 3600);
const SKIP_ROW_TYPES = new Set(['automation', 'history', 'flow']);
const TERMINAL = new Set([2, 4, 5, 9, 99]);

async function activeTransfers(automationId) {
  const result = await call('GET', '/api/transfers', undefined, { automationId });
  const records = Array.isArray(result) ? result : result?.data || [];
  return records.filter((r) => !SKIP_ROW_TYPES.has(r.type));
}

for (const row of await activeTransfers(automationId)) {
  const mid = row.monitorId || row.id;
  const detail = (await call('GET', `/api/transfers/${mid}`)) || {};
  console.log(`  ${mid}: status=${detail.status} (${detail.percent ?? 0}%)`);
}
static readonly HashSet<int> Terminal = new() { 2, 4, 5, 9, 99 };
static readonly HashSet<string> SkipRowTypes = new() { "automation", "history", "flow" };

static async Task PrintActive(string automationId)
{
    var result = await Call("GET", "/api/transfers?automationId=" + automationId);
    var records = result.ValueKind == JsonValueKind.Object && result.TryGetProperty("data", out var inner)
        ? inner : result;

    foreach (var r in records.EnumerateArray())
    {
        var type = r.TryGetProperty("type", out var t) ? t.GetString() : null;
        if (type != null && SkipRowTypes.Contains(type)) continue;

        var mid = r.TryGetProperty("monitorId", out var m) ? m.GetString()
                : r.TryGetProperty("id", out var i) ? i.GetString() : null;
        if (mid == null) continue;

        var detail = await Call("GET", "/api/transfers/" + mid);
        Console.WriteLine(
quot; {mid}: status={detail.GetProperty("status").GetInt32()}"
); } }

전송 옵션#

시작 시점과 반복 주기#

실행 시점은 schedules[0] 하나로 정합니다. details 는 그대로 두고 이 객체만 바꾸면 됩니다.

실행 시점 schedules[0]
지금 바로 { "type": "none", "startDateType": "now", "startDate": "<현재 ISO>", "timezone": "Asia/Seoul" }
지정 시각에 1회 { "type": "none", "startDateType": "specific", "startDate": "2026-09-20T01:00:00", "timezone": "Asia/Seoul" }
주기 반복 아래 주기 표 참고
이전 자동화가 끝난 뒤 { "type": "none", "startDateType": "now", "triggerAutomation": { "value": "<이전 automationId>" }, ... } — 바디에 flowId 추가
외부 요청으로 { "type": "none", "startDateType": "now", ... } + 바디 transferType: "command"

외부 요청으로 시작하려면 두 번의 사전 호출이 필요합니다. POST /api/command/generate-code → data.code, GET /api/command/generate-api-key → data.apiKey. 두 값을 자동화 바디의 code · apiKey 로 넣어 생성한 뒤, POST https://app.innorix.com/command/<code> 를 x-api-key: <apiKey> 헤더로 호출하면 수집이 시작됩니다.

수집은 대개 정해진 시각에 반복됩니다. 그때는 schedules[0] 을 다음처럼 씁니다.

주기 schedules[0]
매시 { "type": "hour", "startDateType": "specific", "startDate": "...", "timezone": "Asia/Seoul" }
매일 02:00 { "type": "day", "hour": "02", "minute": "00", "ampm": "am", ... }
매주 월요일 { "type": "week", "dayInWeek": ["monday"], "hour": "02", "minute": "00", "ampm": "am", ... }
매월 1일 { "type": "month", "dayInMonth": ["1"], "hour": "02", "minute": "00", "ampm": "am", ... }
  • hour 는 1–12, ampm 은 am / pm 입니다. type: "hour" 에서는 hour 값이 무시됩니다.
  • dayInWeek · dayInMonth 는 배열이라 ["monday","wednesday"], ["1","15"] 처럼 여러 개를 넣을 수 있습니다.
  • dayInMonth 의 0 은 말일을 뜻합니다.
  • startDateType: "now" 면 생성 즉시 한 번 실행하고 이후 주기대로, "specific" 이면 startDate 로 지정한 첫 실행 시각부터 시작합니다.

파일명 충돌#

수집에서 가장 흔한 문제는 지점마다 같은 이름(daily.csv)을 보내는 경우입니다. 선택지는 세 가지입니다.

방법 설정 결과
디바이스명 폴더로 분리 (권장) savepath: true, optionPath: 2 D:/collected/branch-01/daily.csv
날짜 폴더로 분리 savepath: true, optionPath: 1 D:/collected/260915/daily.csv
같은 폴더에 이름 변경 target-action: "numbering" daily.csv, daily (1).csv …

target-action 값은 overwrite(덮어쓰기) · numbering(이름 뒤에 번호) · nosend(건너뛰기) 셋입니다.

하위 폴더를 만들지 않고 원본 폴더 구조만 그대로 유지하려면 savepath: true 만 주고 optionPath 를 생략합니다.

파일 옵션#

파일 처리 옵션은 details[].transferOptions 안에 넣습니다.

옵션 키 값
확장자 필터 send-fileoption.extension { "extension": ["pdf","mp4"], "allow": true } — 차단 목록이면 allow: false
크기 필터 send-fileoption.fileSize { "size": <바이트>, "over": true, "equal": true } — 하한은 over: true, 상한은 over: false (한쪽만 지정 가능)
이름 필터 send-fileoption.fileName { "name": "temp", "allow": false } — 이름에 포함되면 제외
폴더 구조 유지 savepath true
날짜 하위 폴더 savepath + optionPath true + 1
디바이스명 하위 폴더 savepath + optionPath true + 2
사용자 지정 하위 폴더 savepath + optionPath "<폴더명>" + 3
중복 이름 — 덮어쓰기 target-action "overwrite"
중복 이름 — 이름 뒤에 번호 target-action "numbering"
중복 이름 — 건너뛰기 target-action "nosend"
무결성 검증 checkIntegrity true
json
{
  "noSchedule": false,
  "target-action": "numbering",
  "checkIntegrity": true,
  "savepath": true,
  "optionPath": 1,
  "send-fileoption": {
    "extension": { "extension": ["pdf", "xlsx"], "allow": true },
    "fileSize": { "size": 1048576, "over": true, "equal": true },
    "fileName": { "name": "tmp", "allow": false }
  }
}

ℹ️ optionPath(날짜 · 디바이스명 · 사용자 지정 하위 폴더)는 자동화에서만 적용됩니다. 이 문서의 모든 전송은 POST /api/automations 로 만들어지므로 Start → Now 여도 그대로 적용됩니다.

전송 후 동작#

전송이 끝난 뒤의 동작은 두 갈래로 나뉩니다.

① 자동화에 붙는 프로세서 — 바디의 processors[]

json
{
  "processors": [
    { "events": "Run", "type": "https", "method": "POST",
      "url": "https://api.example.com/webhook", "body": "{\"event\":\"done\"}" },
    { "category": "monitoring", "type": "grafana", "name": "builder-grafana",
      "config": { "baseUrl": "https://grafana.company.com", "apiToken": "***" },
      "notificationConfig": { "events": { "completed": true, "error": true } } }
  ]
}
  • Run API — 전송마다 호출되는 HTTP 훅입니다.
  • Monitoring(Grafana · Datadog · Prometheus 등) — 워크스페이스 전역이 아니라 이 자동화에 붙습니다. 선택 가능한 이벤트는 started · completed · paused · recovered · deviceConnected · deviceDisconnected 입니다.

② 워크스페이스 전역 연동 — POST /api/integrations

Message(Slack · Teams · Discord …), Virus scan(ClamAV · Microsoft Defender …), Email(SES · SendGrid) 은 전송이 아니라 워크스페이스에 등록됩니다.

json
{
  "name": "builder-slack",
  "type": "slack",
  "category": "notification",
  "config": { "webhookUrl": "https://hooks.slack.com/services/XXX", "channel": "#transfers" },
  "notificationConfig": { "events": { "completed": true, "error": true } }
}

category 는 Message → notification, Virus scan → security, Email → email 입니다. 제공자별 필수 설정 항목은 GET /api/integrations/rules/{type} 으로 확인할 수 있습니다. 이벤트 이름은 started · completed · paused · resumed · recovered · canceled · error · skipped 입니다.

참고#

빌더 UI ↔ .env ↔ API 매핑#

빌더 UI .env API
탭 = 여러 대에서 한 대로 모으기 TRANSFER_TYPE=collect details N개
From 디바이스 목록 SOURCE_IDS (콤마 구분) details[].senderId
From 경로 SOURCE_PATHS (1개 또는 N개) details[].sourceItem[].filePath
To 디바이스 TARGET_ID 모든 details[].receiverId (공통)
To 경로 TARGET_PATH 모든 details[].targetPath (공통)
Start START_WHEN · REPEAT_* schedules[0]
Save Path SAVE_PATH savepath · optionPath
Duplicated Name DUPLICATE_ACTION target-action
After transfer ON_* processors[] · POST /api/integrations

SOURCE_IDS 가 비어 있으면 예제는 단일 SOURCE_ID 를 1개짜리 목록으로 대신 사용합니다.

자주 겪는 오류#

증상 원인과 해결
파일이 서로 덮어써짐 target-action 기본값이 overwrite 입니다. numbering 으로 바꾸거나 optionPath: 2 로 분리하세요.
하위 폴더가 안 생김 optionPath 만으로는 동작하지 않습니다. savepath: true 를 함께 보내야 합니다.
일부 지점만 수집됨 해당 에이전트가 오프라인이거나 sourceItem[].filePath 가 그 장비에 없는 경로입니다.
반복인데 한 번만 실행됨 isUpcoming: true 로 보내면 서버가 1회성 예약으로 바꿉니다. false 로 두세요.
첫 실행이 바로 일어남 startDateType: "now" 는 생성 즉시 1회 실행합니다. 원치 않으면 "specific" + 첫 실행 시각을 주세요.
이전여러 곳으로 파일 보내기다음시스템 간 파일 동기화

이 페이지에서

  • 시작하기
  • 준비물
  • 빠른 시작
  • 전송 만들기
  • 전송 만들기
  • 진행 상황 확인
  • 전송 옵션
  • 시작 시점과 반복 주기
  • 파일명 충돌
  • 파일 옵션
  • 전송 후 동작
  • 참고
  • 빌더 UI ↔ .env ↔ API 매핑
  • 자주 겪는 오류